Reference implementation for the Phoenix Architecture. Work in progress. aicoding.leaflet.pub/
ai coding crazy
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Redesigned phoenix inspect with focus mode, SVG lines, and graph overlay

Three major UX improvements to the pipeline visualisation:

1. Focus mode: clicking any node hides everything unconnected.
Only the causal chain remains visible across all 5 columns.
Toggling All/Focus in the header switches between full view
and filtered view.

2. SVG connection lines: bezier curves drawn between connected
cards across columns. Thicker/brighter lines for direct
connections to the selected node. Lines update on scroll/resize.

3. Graph overlay (press G or click ⬡ Graph): full-screen layered
graph showing just the selected subgraph with proper
node positioning by column and SVG edge routing.

Interaction: click a node → auto-enters focus mode → shows only
its chain with drawn connections. Press G for graph view.
Escape to back out. Click empty space to deselect.

+246 -300
+246 -300
src/inspect.ts
··· 234 234 <meta name="viewport" content="width=device-width,initial-scale=1"> 235 235 <title>Phoenix · ${esc(data.projectName)}</title> 236 236 <style> 237 - :root { 238 - --bg: #0f1117; --surface: #1a1d27; --surface2: #232730; 239 - --border: #2e3345; --text: #e1e4ed; --dim: #7a8194; 240 - --blue: #5b9cf4; --green: #4ade80; --yellow: #fbbf24; 241 - --orange: #fb923c; --red: #f87171; --purple: #a78bfa; 242 - --cyan: #22d3ee; 243 - --font: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace; 244 - } 245 - * { margin:0; padding:0; box-sizing:border-box; } 246 - body { font-family:var(--font); background:var(--bg); color:var(--text); font-size:13px; line-height:1.6; } 237 + :root{--bg:#0f1117;--surface:#1a1d27;--surface2:#232730;--border:#2e3345;--text:#e1e4ed;--dim:#7a8194;--blue:#5b9cf4;--green:#4ade80;--yellow:#fbbf24;--orange:#fb923c;--red:#f87171;--purple:#a78bfa;--cyan:#22d3ee;--font:'SF Mono','Fira Code','JetBrains Mono',monospace} 238 + *{margin:0;padding:0;box-sizing:border-box} 239 + body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:13px;line-height:1.6;overflow:hidden;height:100vh} 240 + .header{background:var(--surface);border-bottom:1px solid var(--border);padding:12px 24px;display:flex;align-items:center;gap:16px;z-index:100} 241 + .header h1{font-size:18px;font-weight:700;color:var(--blue)} 242 + .header .state{font-size:11px;padding:3px 8px;border-radius:4px;background:var(--surface2);color:var(--yellow);border:1px solid var(--border)} 243 + .header .stats{margin-left:auto;display:flex;gap:16px;font-size:11px;color:var(--dim)} 244 + .header .stats b{color:var(--text);font-weight:600} 245 + .mode-btns{display:flex;gap:4px} 246 + .mode-btn{background:var(--surface2);border:1px solid var(--border);color:var(--dim);padding:4px 12px;border-radius:4px;cursor:pointer;font:inherit;font-size:11px} 247 + .mode-btn:hover{border-color:var(--blue);color:var(--text)} 248 + .mode-btn.active{background:var(--blue);color:#fff;border-color:var(--blue)} 247 249 248 - /* Header */ 249 - .header { background:var(--surface); border-bottom:1px solid var(--border); padding:16px 24px; display:flex; align-items:center; gap:16px; position:sticky; top:0; z-index:100; } 250 - .header h1 { font-size:18px; font-weight:700; color:var(--blue); } 251 - .header .state { font-size:11px; padding:3px 8px; border-radius:4px; background:var(--surface2); color:var(--yellow); border:1px solid var(--border); } 252 - .header .stats { margin-left:auto; display:flex; gap:16px; font-size:11px; color:var(--dim); } 253 - .header .stats span { color:var(--text); font-weight:600; } 250 + /* ── Pipeline columns ── */ 251 + .pipeline-wrap{display:flex;height:calc(100vh - 52px);position:relative} 252 + .pipeline{display:flex;flex:1;overflow:hidden} 253 + .column{flex:1;min-width:0;border-right:1px solid var(--border);display:flex;flex-direction:column} 254 + .column:last-child{border-right:none} 255 + .col-header{padding:8px 12px;background:var(--surface);border-bottom:1px solid var(--border);font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:var(--dim);display:flex;justify-content:space-between} 256 + .col-header .ct{color:var(--blue)} 257 + .col-body{flex:1;overflow-y:auto;padding:6px} 258 + .card{background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin-bottom:4px;cursor:pointer;transition:all .15s;position:relative} 259 + .card:hover{border-color:var(--blue);background:var(--surface2)} 260 + .card.hl{border-color:var(--cyan);background:#142535;box-shadow:0 0 8px rgba(34,211,238,.2)} 261 + .card.sel{border-color:var(--cyan);background:#1a3040;box-shadow:0 0 16px rgba(34,211,238,.35);ring:2px solid var(--cyan)} 262 + .card.hide{display:none} 263 + .card .t{font-size:11px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} 264 + .card .s{font-size:9px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px} 265 + .badge{display:inline-block;font-size:8px;font-weight:700;padding:1px 5px;border-radius:3px;text-transform:uppercase;letter-spacing:.5px;vertical-align:middle} 266 + .b-req{background:#1e3a5f;color:var(--blue)}.b-con{background:#3b1e1e;color:var(--red)}.b-inv{background:#2d1e3f;color:var(--purple)}.b-def{background:#1e2d1e;color:var(--green)} 267 + .b-low{background:#1e2d1e;color:var(--green)}.b-medium{background:#2d2a1e;color:var(--yellow)}.b-high{background:#2d1e1e;color:var(--orange)}.b-critical{background:#3b1e1e;color:var(--red)} 268 + .b-clean{background:#1e2d1e;color:var(--green)}.b-drifted{background:#3b1e1e;color:var(--red)}.b-missing{background:#2d1e1e;color:var(--orange)}.b-unknown{background:var(--surface2);color:var(--dim)} 269 + .tag{display:inline-block;font-size:8px;padding:1px 4px;border-radius:2px;background:var(--surface2);color:var(--dim);margin:1px} 254 270 255 - /* Pipeline */ 256 - .pipeline { display:flex; min-height:calc(100vh - 56px); } 257 - .column { flex:1; min-width:0; border-right:1px solid var(--border); display:flex; flex-direction:column; } 258 - .column:last-child { border-right:none; } 259 - .col-header { padding:10px 14px; background:var(--surface); border-bottom:1px solid var(--border); font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:1px; color:var(--dim); display:flex; justify-content:space-between; position:sticky; top:56px; z-index:50; } 260 - .col-header .count { color:var(--blue); } 261 - .col-body { flex:1; overflow-y:auto; padding:8px; } 271 + /* ── SVG overlay for connection lines ── */ 272 + svg.lines{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:10} 273 + svg.lines path{fill:none;stroke:var(--cyan);stroke-width:1.5;opacity:.6} 274 + svg.lines path.strong{stroke-width:2.5;opacity:1;filter:drop-shadow(0 0 4px rgba(34,211,238,.5))} 262 275 263 - /* Cards */ 264 - .card { background:var(--surface); border:1px solid var(--border); border-radius:6px; padding:10px 12px; margin-bottom:6px; cursor:pointer; transition:border-color .15s, background .15s; position:relative; } 265 - .card:hover { border-color:var(--blue); background:var(--surface2); } 266 - .card.highlighted { border-color:var(--cyan); background:#1a2a3a; box-shadow:0 0 12px rgba(34,211,238,.15); } 267 - .card.dimmed { opacity:.25; } 268 - .card-title { font-size:12px; font-weight:600; margin-bottom:3px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } 269 - .card-sub { font-size:10px; color:var(--dim); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } 270 - .card-body { font-size:11px; color:var(--dim); margin-top:4px; display:none; } 271 - .card.expanded .card-body { display:block; } 272 - 273 - /* Tags / badges */ 274 - .badge { display:inline-block; font-size:9px; font-weight:600; padding:1px 6px; border-radius:3px; text-transform:uppercase; letter-spacing:.5px; } 275 - .badge-req { background:#1e3a5f; color:var(--blue); } 276 - .badge-con { background:#3b1e1e; color:var(--red); } 277 - .badge-inv { background:#2d1e3f; color:var(--purple); } 278 - .badge-def { background:#1e2d1e; color:var(--green); } 279 - .badge-low { background:#1e2d1e; color:var(--green); } 280 - .badge-medium { background:#2d2a1e; color:var(--yellow); } 281 - .badge-high { background:#2d1e1e; color:var(--orange); } 282 - .badge-critical { background:#3b1e1e; color:var(--red); } 283 - .badge-clean { background:#1e2d1e; color:var(--green); } 284 - .badge-drifted { background:#3b1e1e; color:var(--red); } 285 - .badge-missing { background:#2d1e1e; color:var(--orange); } 286 - .tag { display:inline-block; font-size:9px; padding:1px 5px; border-radius:2px; background:var(--surface2); color:var(--dim); margin:1px; } 287 - 288 - /* Detail panel */ 289 - .detail-panel { position:fixed; right:0; top:56px; width:380px; height:calc(100vh - 56px); background:var(--surface); border-left:2px solid var(--blue); z-index:200; overflow-y:auto; padding:20px; transform:translateX(100%); transition:transform .2s ease; } 290 - .detail-panel.open { transform:translateX(0); } 291 - .detail-panel h2 { font-size:14px; margin-bottom:12px; color:var(--blue); } 292 - .detail-panel .close { position:absolute; top:12px; right:14px; background:none; border:none; color:var(--dim); cursor:pointer; font-size:16px; } 293 - .detail-panel .close:hover { color:var(--text); } 294 - .detail-section { margin-bottom:14px; } 295 - .detail-section h3 { font-size:11px; text-transform:uppercase; letter-spacing:.5px; color:var(--dim); margin-bottom:4px; } 296 - .detail-section p, .detail-section li { font-size:12px; color:var(--text); line-height:1.5; } 297 - .detail-section ul { padding-left:16px; } 298 - .detail-section .mono { font-family:var(--font); font-size:11px; color:var(--cyan); word-break:break-all; } 299 - .provenance-chain { margin-top:8px; } 300 - .provenance-step { display:flex; align-items:flex-start; gap:8px; margin-bottom:8px; padding:6px 8px; background:var(--surface2); border-radius:4px; font-size:11px; } 301 - .provenance-step .arrow { color:var(--blue); font-weight:bold; flex-shrink:0; } 302 - .provenance-step .label { color:var(--dim); font-size:10px; } 303 - .provenance-step .value { color:var(--text); } 304 - 305 - /* Connection lines (CSS, no canvas) */ 306 - .flow-indicator { position:absolute; right:-4px; top:50%; width:8px; height:8px; border-radius:50%; background:var(--border); transform:translateY(-50%); } 307 - .card.highlighted .flow-indicator { background:var(--cyan); box-shadow:0 0 6px var(--cyan); } 308 - 309 - /* Search */ 310 - .search { padding:8px; border-bottom:1px solid var(--border); position:sticky; top:56px; z-index:50; background:var(--surface); } 311 - .search input { width:100%; background:var(--surface2); border:1px solid var(--border); color:var(--text); padding:6px 10px; border-radius:4px; font-size:12px; font-family:var(--font); } 312 - .search input:focus { outline:none; border-color:var(--blue); } 276 + /* ── Graph overlay ── */ 277 + .graph-overlay{position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:500;display:none;flex-direction:column} 278 + .graph-overlay.open{display:flex} 279 + .graph-bar{padding:12px 24px;display:flex;align-items:center;gap:16px;background:var(--surface);border-bottom:1px solid var(--border)} 280 + .graph-bar h2{font-size:15px;color:var(--cyan)} 281 + .graph-bar .close{background:none;border:1px solid var(--border);color:var(--dim);padding:4px 12px;border-radius:4px;cursor:pointer;font:inherit;font-size:11px;margin-left:auto} 282 + .graph-bar .close:hover{border-color:var(--red);color:var(--red)} 283 + .graph-body{flex:1;overflow:auto;display:flex;justify-content:center;padding:40px} 284 + .graph-canvas{position:relative} 285 + .gn{position:absolute;background:var(--surface);border:2px solid var(--border);border-radius:8px;padding:10px 14px;font-size:11px;max-width:220px;cursor:default;z-index:2;transition:border-color .15s} 286 + .gn:hover{border-color:var(--blue)} 287 + .gn.gn-sel{border-color:var(--cyan);box-shadow:0 0 16px rgba(34,211,238,.4)} 288 + .gn .gn-label{font-size:9px;text-transform:uppercase;color:var(--dim);letter-spacing:.5px;margin-bottom:3px} 289 + .gn .gn-text{font-weight:600;color:var(--text);word-break:break-word} 290 + svg.graph-edges{position:absolute;top:0;left:0;pointer-events:none;z-index:1} 291 + svg.graph-edges path{fill:none;stroke:var(--cyan);stroke-width:2;opacity:.5} 292 + svg.graph-edges path.primary{stroke-width:3;opacity:.9;filter:drop-shadow(0 0 4px rgba(34,211,238,.4))} 293 + .graph-hint{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);font-size:11px;color:var(--dim);text-align:center} 313 294 </style> 314 295 </head> 315 296 <body> 316 - 317 297 <div class="header"> 318 298 <h1>🔥 Phoenix</h1> 319 299 <div class="state">${esc(data.systemState)}</div> 300 + <div class="mode-btns"> 301 + <button class="mode-btn active" onclick="setMode('all')" id="btn-all">All</button> 302 + <button class="mode-btn" onclick="setMode('focus')" id="btn-focus">Focus</button> 303 + <button class="mode-btn" onclick="openGraph()" id="btn-graph">⬡ Graph</button> 304 + </div> 320 305 <div class="stats"> 321 - <div><span>${data.stats.specFiles}</span> specs</div> 322 - <div><span>${data.stats.clauses}</span> clauses</div> 323 - <div><span>${data.stats.canonNodes}</span> canon</div> 324 - <div><span>${data.stats.ius}</span> IUs</div> 325 - <div><span>${data.stats.generatedFiles}</span> files</div> 326 - <div><span>${data.stats.edgeCount}</span> edges</div> 327 - <div>${data.stats.driftDirty > 0 ? `<span style="color:var(--red)">${data.stats.driftDirty} drift</span>` : '<span style="color:var(--green)">clean</span>'}</div> 306 + <div><b>${data.stats.specFiles}</b> specs</div> 307 + <div><b>${data.stats.clauses}</b> clauses</div> 308 + <div><b>${data.stats.canonNodes}</b> canon</div> 309 + <div><b>${data.stats.ius}</b> IUs</div> 310 + <div><b>${data.stats.generatedFiles}</b> files</div> 311 + <div>${data.stats.driftDirty>0?`<b style="color:var(--red)">${data.stats.driftDirty} drift</b>`:'<b style="color:var(--green)">clean</b>'}</div> 328 312 </div> 329 313 </div> 330 - 331 - <div class="pipeline" id="pipeline"> 332 - <!-- Columns rendered by JS --> 314 + <div class="pipeline-wrap"> 315 + <svg class="lines" id="svg-lines"></svg> 316 + <div class="pipeline" id="pipeline"></div> 333 317 </div> 334 - 335 - <div class="detail-panel" id="detail"> 336 - <button class="close" onclick="closeDetail()">✕</button> 337 - <div id="detail-content"></div> 318 + <div class="graph-overlay" id="graph-overlay"> 319 + <div class="graph-bar"> 320 + <h2 id="graph-title">Provenance Graph</h2> 321 + <button class="close" onclick="closeGraph()">✕ Close</button> 322 + </div> 323 + <div class="graph-body"><div class="graph-canvas" id="graph-canvas"></div></div> 338 324 </div> 339 325 340 326 <script> 341 - const DATA = ${json}; 342 - 343 - // Build lookup indices 344 - const edgeIndex = { forward: {}, backward: {} }; 345 - DATA.edges.forEach(e => { 346 - (edgeIndex.forward[e.from] = edgeIndex.forward[e.from] || []).push(e.to); 347 - (edgeIndex.backward[e.to] = edgeIndex.backward[e.to] || []).push(e.from); 348 - }); 327 + const D=${json}; 328 + const COL_ORDER=['spec','clause','canon','iu','file']; 329 + const COL_ICON={spec:'📄',clause:'📋',canon:'📐',iu:'📦',file:'⚡'}; 349 330 350 - const allItems = {}; 351 - DATA.specFiles.forEach(s => allItems['spec:'+s.path] = { col:'spec', data:s }); 352 - DATA.clauses.forEach(c => allItems['clause:'+c.id] = { col:'clause', data:c }); 353 - DATA.canonNodes.forEach(n => allItems['canon:'+n.id] = { col:'canon', data:n }); 354 - DATA.ius.forEach(u => allItems['iu:'+u.id] = { col:'iu', data:u }); 355 - DATA.generatedFiles.forEach(f => allItems['file:'+f.path] = { col:'file', data:f }); 331 + // indices 332 + const fwd={},bwd={}; 333 + D.edges.forEach(e=>{(fwd[e.from]=fwd[e.from]||[]).push(e.to);(bwd[e.to]=bwd[e.to]||[]).push(e.from)}); 334 + const items={}; 335 + D.specFiles.forEach(s=>items['spec:'+s.path]={col:'spec',d:s}); 336 + D.clauses.forEach(c=>items['clause:'+c.id]={col:'clause',d:c}); 337 + D.canonNodes.forEach(n=>items['canon:'+n.id]={col:'canon',d:n}); 338 + D.ius.forEach(u=>items['iu:'+u.id]={col:'iu',d:u}); 339 + D.generatedFiles.forEach(f=>items['file:'+f.path]={col:'file',d:f}); 356 340 357 - function esc(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } 341 + function E(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')} 358 342 359 - // Render columns 360 - function renderPipeline() { 361 - const p = document.getElementById('pipeline'); 362 - p.innerHTML = [ 363 - renderColumn('Spec Files', 'spec', DATA.specFiles.map(s => 364 - card('spec:'+s.path, s.path.split('/').pop(), s.clauseCount+' clauses', '') 365 - )), 366 - renderColumn('Clauses', 'clause', DATA.clauses.map(c => 367 - card('clause:'+c.id, c.sectionPath, c.lineRange+' · '+c.semhash+'…', c.preview) 368 - )), 369 - renderColumn('Canonical Nodes', 'canon', DATA.canonNodes.map(n => 370 - card('canon:'+n.id, 371 - '<span class="badge badge-'+n.type.toLowerCase().slice(0,3)+'">'+n.type+'</span> '+esc(n.statement.slice(0,60)), 372 - n.tags.slice(0,5).map(t=>'<span class="tag">'+esc(t)+'</span>').join('')+(n.linkCount?' · '+n.linkCount+' links':''), 373 - esc(n.statement)) 374 - )), 375 - renderColumn('Implementation Units', 'iu', DATA.ius.map(u => 376 - card('iu:'+u.id, 377 - esc(u.name)+' <span class="badge badge-'+u.riskTier+'">'+u.riskTier+'</span>', 378 - u.canonCount+' nodes · '+u.outputFiles.length+' file(s)', 379 - esc(u.description.slice(0,200))) 380 - )), 381 - renderColumn('Generated Files', 'file', DATA.generatedFiles.map(f => 382 - card('file:'+f.path, 383 - esc(f.path.split('/').pop())+' <span class="badge badge-'+f.driftStatus.toLowerCase()+'">'+f.driftStatus+'</span>', 384 - esc(f.iuName)+' · '+f.contentHash+'… · '+(f.size/1024).toFixed(1)+'KB', 385 - esc(f.path)) 386 - )), 387 - ].join(''); 343 + // ── Traversal (skip canon↔canon to keep pipeline linear) ── 344 + function getConnected(id){ 345 + const set=new Set([id]); 346 + const q=[id]; 347 + while(q.length){const n=q.shift(); 348 + for(const t of(fwd[n]||[])){if(!set.has(t)&&!(n.startsWith('canon:')&&t.startsWith('canon:'))){set.add(t);q.push(t)}} 349 + for(const t of(bwd[n]||[])){if(!set.has(t)&&!(n.startsWith('canon:')&&t.startsWith('canon:'))){set.add(t);q.push(t)}} 350 + } 351 + return set; 388 352 } 389 353 390 - function renderColumn(title, type, cards) { 391 - return '<div class="column"><div class="col-header"><span>'+title+'</span><span class="count">'+cards.length+'</span></div>' 392 - +'<div class="search"><input type="text" placeholder="Filter '+title.toLowerCase()+'…" oninput="filterColumn(this,\\''+type+'\\')"></div>' 393 - +'<div class="col-body" data-col="'+type+'">'+cards.join('')+'</div></div>'; 394 - } 354 + // ── Card HTML ── 355 + function nodeTitle(id){const it=items[id];if(!it)return id;const d=it.d; 356 + if(it.col==='spec')return E(d.path.split('/').pop()); 357 + if(it.col==='clause')return E(d.sectionPath); 358 + if(it.col==='canon')return'<span class="badge b-'+d.type.slice(0,3).toLowerCase()+'">'+d.type+'</span> '+E(d.statement.slice(0,55)); 359 + if(it.col==='iu')return E(d.name)+' <span class="badge b-'+d.riskTier+'">'+d.riskTier+'</span>'; 360 + if(it.col==='file')return E(d.path.split('/').pop())+' <span class="badge b-'+d.driftStatus.toLowerCase()+'">'+d.driftStatus+'</span>'; 361 + return id;} 362 + function nodeSub(id){const it=items[id];if(!it)return'';const d=it.d; 363 + if(it.col==='spec')return d.clauseCount+' clauses'; 364 + if(it.col==='clause')return d.lineRange+' · '+d.semhash+'…'; 365 + if(it.col==='canon')return d.tags.slice(0,4).map(t=>'<span class="tag">'+E(t)+'</span>').join('')+(d.linkCount?' · '+d.linkCount+' links':''); 366 + if(it.col==='iu')return d.canonCount+' nodes · '+d.outputFiles.length+' file(s)'; 367 + if(it.col==='file')return E(d.iuName)+' · '+(d.size/1024).toFixed(1)+'KB'; 368 + return'';} 369 + function crd(id){return'<div class="card" data-id="'+E(id)+'"><div class="t">'+nodeTitle(id)+'</div><div class="s">'+nodeSub(id)+'</div></div>';} 395 370 396 - function card(id, title, sub, body) { 397 - return '<div class="card" data-id="'+esc(id)+'" onclick="selectCard(\\''+esc(id).replace(/'/g,"\\\\'")+'\\')">' 398 - +'<div class="card-title">'+title+'</div>' 399 - +'<div class="card-sub">'+sub+'</div>' 400 - +(body ? '<div class="card-body">'+body+'</div>' : '') 401 - +'<div class="flow-indicator"></div>' 402 - +'</div>'; 371 + // ── Render pipeline ── 372 + function render(){ 373 + const cols=[ 374 + {title:'Spec Files',col:'spec',ids:D.specFiles.map(s=>'spec:'+s.path)}, 375 + {title:'Clauses',col:'clause',ids:D.clauses.map(c=>'clause:'+c.id)}, 376 + {title:'Canonical Nodes',col:'canon',ids:D.canonNodes.map(n=>'canon:'+n.id)}, 377 + {title:'Implementation Units',col:'iu',ids:D.ius.map(u=>'iu:'+u.id)}, 378 + {title:'Generated Files',col:'file',ids:D.generatedFiles.map(f=>'file:'+f.path)}, 379 + ]; 380 + document.getElementById('pipeline').innerHTML=cols.map(c=> 381 + '<div class="column" data-col="'+c.col+'"><div class="col-header"><span>'+c.title+'</span><span class="ct">'+c.ids.length+'</span></div><div class="col-body">'+c.ids.map(crd).join('')+'</div></div>' 382 + ).join(''); 383 + document.querySelectorAll('.card').forEach(el=>{el.addEventListener('click',()=>selectCard(el.dataset.id))}); 403 384 } 404 385 405 - // Selection + highlighting 406 - let selectedId = null; 407 - 408 - function selectCard(id) { 409 - selectedId = id; 410 - // Collect all connected nodes (forward + backward, 2 hops) 411 - const connected = new Set([id]); 412 - const queue = [id]; 413 - for (let depth = 0; depth < 6; depth++) { 414 - const next = []; 415 - for (const n of queue) { 416 - for (const t of (edgeIndex.forward[n]||[])) { if (!connected.has(t)) { connected.add(t); next.push(t); } } 417 - for (const t of (edgeIndex.backward[n]||[])) { if (!connected.has(t)) { connected.add(t); next.push(t); } } 418 - } 419 - queue.length = 0; 420 - queue.push(...next); 421 - } 386 + // ── Mode + selection ── 387 + let mode='all',selId=null,connected=new Set(); 422 388 423 - document.querySelectorAll('.card').forEach(el => { 424 - const cid = el.dataset.id; 425 - el.classList.toggle('highlighted', connected.has(cid)); 426 - el.classList.toggle('dimmed', !connected.has(cid)); 427 - }); 428 - 429 - showDetail(id); 389 + function setMode(m){ 390 + mode=m; 391 + document.getElementById('btn-all').classList.toggle('active',m==='all'); 392 + document.getElementById('btn-focus').classList.toggle('active',m==='focus'); 393 + applyView(); 430 394 } 431 395 432 - function clearSelection() { 433 - selectedId = null; 434 - document.querySelectorAll('.card').forEach(el => { 435 - el.classList.remove('highlighted','dimmed'); 436 - }); 396 + function selectCard(id){ 397 + selId=id;connected=getConnected(id); 398 + if(mode==='all')setMode('focus'); 399 + else applyView(); 437 400 } 438 401 439 - // Filtering 440 - function filterColumn(input, colType) { 441 - const q = input.value.toLowerCase(); 442 - const col = document.querySelector('[data-col="'+colType+'"]'); 443 - col.querySelectorAll('.card').forEach(el => { 444 - const text = el.textContent.toLowerCase(); 445 - el.style.display = text.includes(q) ? '' : 'none'; 402 + function applyView(){ 403 + const cards=document.querySelectorAll('.card'); 404 + if(!selId||mode==='all'){ 405 + cards.forEach(el=>{el.classList.remove('hl','sel','hide')}); 406 + clearLines();return; 407 + } 408 + cards.forEach(el=>{ 409 + const cid=el.dataset.id; 410 + el.classList.toggle('hl',connected.has(cid)&&cid!==selId); 411 + el.classList.toggle('sel',cid===selId); 412 + el.classList.toggle('hide',!connected.has(cid)); 446 413 }); 414 + requestAnimationFrame(drawLines); 447 415 } 448 416 449 - // Detail panel 450 - function showDetail(id) { 451 - const panel = document.getElementById('detail'); 452 - const content = document.getElementById('detail-content'); 453 - const item = allItems[id]; 454 - if (!item) return; 417 + function deselect(){selId=null;connected.clear();setMode('all');} 455 418 456 - let html = ''; 457 - const d = item.data; 458 - 459 - if (item.col === 'spec') { 460 - html = '<h2>📄 '+esc(d.path)+'</h2>' 461 - +'<div class="detail-section"><h3>Clauses</h3><p>'+d.clauseCount+' clauses extracted</p></div>'; 462 - } 463 - else if (item.col === 'clause') { 464 - html = '<h2>📋 Clause</h2>' 465 - +'<div class="detail-section"><h3>Section</h3><p>'+esc(d.sectionPath)+'</p></div>' 466 - +'<div class="detail-section"><h3>Source</h3><p>'+esc(d.docId)+' '+d.lineRange+'</p></div>' 467 - +'<div class="detail-section"><h3>Content</h3><p>'+esc(d.preview)+'</p></div>' 468 - +'<div class="detail-section"><h3>Semhash</h3><p class="mono">'+esc(d.semhash)+'…</p></div>'; 469 - } 470 - else if (item.col === 'canon') { 471 - html = '<h2>'+canonBadge(d.type)+' Canonical Node</h2>' 472 - +'<div class="detail-section"><h3>Statement</h3><p>'+esc(d.statement)+'</p></div>' 473 - +'<div class="detail-section"><h3>Tags</h3><p>'+d.tags.map(t=>'<span class="tag">'+esc(t)+'</span>').join(' ')+'</p></div>' 474 - +'<div class="detail-section"><h3>Links</h3><p>'+d.linkCount+' cross-references</p></div>' 475 - +'<div class="detail-section"><h3>ID</h3><p class="mono">'+esc(d.id)+'</p></div>'; 476 - } 477 - else if (item.col === 'iu') { 478 - html = '<h2>📦 '+esc(d.name)+'</h2>' 479 - +'<div class="detail-section"><h3>Risk Tier</h3><p><span class="badge badge-'+d.riskTier+'">'+d.riskTier+'</span></p></div>' 480 - +'<div class="detail-section"><h3>Description</h3><p>'+esc(d.description)+'</p></div>' 481 - +(d.invariants.length ? '<div class="detail-section"><h3>Invariants</h3><ul>'+d.invariants.map(i=>'<li>'+esc(i)+'</li>').join('')+'</ul></div>' : '') 482 - +'<div class="detail-section"><h3>Evidence Required</h3><p>'+d.evidenceRequired.join(', ')+'</p></div>' 483 - +'<div class="detail-section"><h3>Output</h3><p class="mono">'+d.outputFiles.map(esc).join('<br>')+'</p></div>' 484 - +'<div class="detail-section"><h3>Canon Nodes</h3><p>'+d.canonCount+' source nodes</p></div>' 485 - +(d.regenMeta ? '<div class="detail-section"><h3>Generation</h3><p>Model: '+esc(d.regenMeta.model_id)+'<br>Generated: '+esc(d.regenMeta.generated_at)+'</p></div>' : '') 486 - +'<div class="detail-section"><h3>ID</h3><p class="mono">'+esc(d.id)+'</p></div>'; 487 - } 488 - else if (item.col === 'file') { 489 - html = '<h2>📄 '+esc(d.path.split('/').pop())+'</h2>' 490 - +'<div class="detail-section"><h3>Path</h3><p class="mono">'+esc(d.path)+'</p></div>' 491 - +'<div class="detail-section"><h3>Status</h3><p><span class="badge badge-'+d.driftStatus.toLowerCase()+'">'+d.driftStatus+'</span></p></div>' 492 - +'<div class="detail-section"><h3>Source IU</h3><p>'+esc(d.iuName)+'</p></div>' 493 - +'<div class="detail-section"><h3>Content Hash</h3><p class="mono">'+esc(d.contentHash)+'…</p></div>' 494 - +'<div class="detail-section"><h3>Size</h3><p>'+(d.size/1024).toFixed(1)+' KB</p></div>'; 419 + // ── SVG connection lines ── 420 + function clearLines(){document.getElementById('svg-lines').innerHTML='';} 421 + function drawLines(){ 422 + const svg=document.getElementById('svg-lines');svg.innerHTML=''; 423 + if(!selId)return; 424 + const wrap=document.querySelector('.pipeline-wrap'); 425 + const wr=wrap.getBoundingClientRect(); 426 + // collect visible card rects 427 + const rects={}; 428 + document.querySelectorAll('.card:not(.hide)').forEach(el=>{ 429 + const r=el.getBoundingClientRect(); 430 + rects[el.dataset.id]={x:r.left-wr.left,y:r.top-wr.top,w:r.width,h:r.height,cx:r.left-wr.left+r.width/2,cy:r.top-wr.top+r.height/2}; 431 + }); 432 + // draw edges between connected nodes that are both visible 433 + const drawn=new Set(); 434 + for(const nid of connected){ 435 + for(const t of(fwd[nid]||[])){ 436 + if(!connected.has(t))continue; 437 + if(nid.startsWith('canon:')&&t.startsWith('canon:'))continue; 438 + const key=nid+'→'+t;if(drawn.has(key))continue;drawn.add(key); 439 + const a=rects[nid],b=rects[t];if(!a||!b)continue; 440 + const x1=a.x+a.w,y1=a.cy,x2=b.x,y2=b.cy; 441 + const dx=(x2-x1)*0.45; 442 + const strong=(nid===selId||t===selId)?'strong':''; 443 + const path=document.createElementNS('http://www.w3.org/2000/svg','path'); 444 + path.setAttribute('d','M'+x1+','+y1+' C'+(x1+dx)+','+y1+' '+(x2-dx)+','+y2+' '+x2+','+y2); 445 + path.setAttribute('class',strong); 446 + svg.appendChild(path); 447 + } 495 448 } 449 + } 496 450 497 - // Provenance chain 498 - html += renderProvenance(id); 499 - 500 - content.innerHTML = html; 501 - panel.classList.add('open'); 451 + // ── Graph overlay ── 452 + function openGraph(){ 453 + if(!selId)return; 454 + const overlay=document.getElementById('graph-overlay'); 455 + overlay.classList.add('open'); 456 + document.getElementById('graph-title').textContent=COL_ICON[items[selId]?.col||'spec']+' Provenance Graph — '+describeShort(selId); 457 + renderGraph(); 502 458 } 459 + function closeGraph(){document.getElementById('graph-overlay').classList.remove('open');} 503 460 504 - function renderProvenance(id) { 505 - // Trace backward to spec 506 - const chain = []; 507 - const visited = new Set(); 508 - function traceBack(nodeId) { 509 - if (visited.has(nodeId)) return; 510 - visited.add(nodeId); 511 - const parents = edgeIndex.backward[nodeId] || []; 512 - for (const p of parents) { 513 - const pItem = allItems[p]; 514 - if (pItem) chain.push({ id: p, col: pItem.col, label: describeNode(p) }); 515 - traceBack(p); 516 - } 517 - } 518 - // Trace forward to files 519 - function traceForward(nodeId) { 520 - if (visited.has(nodeId)) return; 521 - visited.add(nodeId); 522 - const children = edgeIndex.forward[nodeId] || []; 523 - for (const c of children) { 524 - if (c.startsWith('canon:') && id.startsWith('canon:')) continue; // skip canon→canon 525 - const cItem = allItems[c]; 526 - if (cItem) chain.push({ id: c, col: cItem.col, label: describeNode(c) }); 527 - traceForward(c); 528 - } 461 + function renderGraph(){ 462 + const canvas=document.getElementById('graph-canvas'); 463 + if(!selId){canvas.innerHTML='';return;} 464 + // Organize connected nodes by column 465 + const cols={}; 466 + for(const nid of connected){const it=items[nid];if(!it)continue;(cols[it.col]=cols[it.col]||[]).push(nid);} 467 + // Layout: columns left→right, nodes top→bottom within column 468 + const colX={};let cx=0; 469 + const nodePos={}; 470 + const COL_W=240,COL_GAP=100,ROW_H=70,PAD=40; 471 + for(const col of COL_ORDER){ 472 + if(!cols[col])continue; 473 + colX[col]=cx; 474 + cols[col].forEach((nid,i)=>{nodePos[nid]={x:cx,y:i*ROW_H}}); 475 + cx+=COL_W+COL_GAP; 529 476 } 530 - 531 - const backChain = []; 532 - const fwdChain = []; 533 - const bVisited = new Set([id]); 534 - function tb(nid) { for (const p of (edgeIndex.backward[nid]||[])) { if (!bVisited.has(p)) { bVisited.add(p); const it = allItems[p]; if(it) backChain.push({id:p,col:it.col,label:describeNode(p)}); tb(p); } } } 535 - const fVisited = new Set([id]); 536 - function tf(nid) { for (const c of (edgeIndex.forward[nid]||[])) { if (!fVisited.has(c) && !(c.startsWith('canon:')&&id.startsWith('canon:'))) { fVisited.add(c); const it = allItems[c]; if(it) fwdChain.push({id:c,col:it.col,label:describeNode(c)}); tf(c); } } } 537 - tb(id); tf(id); 538 - 539 - if (backChain.length === 0 && fwdChain.length === 0) return ''; 540 - 541 - let html = '<div class="detail-section"><h3>Provenance Chain</h3><div class="provenance-chain">'; 542 - const colIcon = { spec:'📄', clause:'📋', canon:'📐', iu:'📦', file:'⚡' }; 543 - for (const step of backChain.reverse()) { 544 - html += '<div class="provenance-step"><span class="arrow">↑</span><div><div class="label">'+(colIcon[step.col]||'')+' '+step.col+'</div><div class="value">'+esc(step.label)+'</div></div></div>'; 477 + const totalW=cx-COL_GAP+PAD*2; 478 + const maxRows=Math.max(...COL_ORDER.map(c=>(cols[c]||[]).length)); 479 + const totalH=maxRows*ROW_H+PAD*2; 480 + canvas.style.width=totalW+'px';canvas.style.height=totalH+'px'; 481 + let html='<svg class="graph-edges" width="'+totalW+'" height="'+totalH+'"></svg>'; 482 + // Nodes 483 + for(const nid of connected){ 484 + const pos=nodePos[nid];if(!pos)continue; 485 + const it=items[nid]; 486 + const isSel=nid===selId?'gn-sel':''; 487 + html+='<div class="gn '+isSel+'" style="left:'+(pos.x+PAD)+'px;top:'+(pos.y+PAD)+'px;width:'+COL_W+'px">' 488 + +'<div class="gn-label">'+COL_ICON[it.col]+' '+it.col+'</div>' 489 + +'<div class="gn-text">'+nodeTitle(nid)+'</div></div>'; 545 490 } 546 - html += '<div class="provenance-step" style="border-left:2px solid var(--cyan);"><span class="arrow">●</span><div><div class="label" style="color:var(--cyan)">selected</div><div class="value">'+esc(describeNode(id))+'</div></div></div>'; 547 - for (const step of fwdChain) { 548 - html += '<div class="provenance-step"><span class="arrow">↓</span><div><div class="label">'+(colIcon[step.col]||'')+' '+step.col+'</div><div class="value">'+esc(step.label)+'</div></div></div>'; 491 + canvas.innerHTML=html; 492 + // Draw edges 493 + const svgEl=canvas.querySelector('svg.graph-edges'); 494 + const drawn2=new Set(); 495 + for(const nid of connected){ 496 + for(const t of(fwd[nid]||[])){ 497 + if(!connected.has(t))continue; 498 + if(nid.startsWith('canon:')&&t.startsWith('canon:'))continue; 499 + const key=nid+'→'+t;if(drawn2.has(key))continue;drawn2.add(key); 500 + const a=nodePos[nid],b=nodePos[t];if(!a||!b)continue; 501 + const x1=a.x+PAD+COL_W,y1=a.y+PAD+30,x2=b.x+PAD,y2=b.y+PAD+30; 502 + const dx=(x2-x1)*0.4; 503 + const cls=(nid===selId||t===selId)?'primary':''; 504 + const p=document.createElementNS('http://www.w3.org/2000/svg','path'); 505 + p.setAttribute('d','M'+x1+','+y1+' C'+(x1+dx)+','+y1+' '+(x2-dx)+','+y2+' '+x2+','+y2); 506 + p.setAttribute('class',cls); 507 + svgEl.appendChild(p); 508 + } 549 509 } 550 - html += '</div></div>'; 551 - return html; 552 510 } 553 511 554 - function describeNode(id) { 555 - const item = allItems[id]; 556 - if (!item) return id; 557 - const d = item.data; 558 - if (item.col === 'spec') return d.path; 559 - if (item.col === 'clause') return d.sectionPath + ' ' + d.lineRange; 560 - if (item.col === 'canon') return d.statement.slice(0,80); 561 - if (item.col === 'iu') return d.name + ' (' + d.riskTier + ')'; 562 - if (item.col === 'file') return d.path; 563 - return id; 564 - } 565 - 566 - function canonBadge(type) { 567 - const cls = {REQUIREMENT:'req',CONSTRAINT:'con',INVARIANT:'inv',DEFINITION:'def'}[type]||'req'; 568 - return '<span class="badge badge-'+cls+'">'+type+'</span>'; 569 - } 570 - 571 - function closeDetail() { 572 - document.getElementById('detail').classList.remove('open'); 573 - clearSelection(); 574 - } 512 + function describeShort(id){const it=items[id];if(!it)return id;const d=it.d; 513 + if(it.col==='spec')return d.path;if(it.col==='clause')return d.sectionPath; 514 + if(it.col==='canon')return d.statement.slice(0,50)+'…'; 515 + if(it.col==='iu')return d.name;if(it.col==='file')return d.path.split('/').pop();return id;} 575 516 576 - document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDetail(); }); 577 - document.addEventListener('click', e => { 578 - if (!e.target.closest('.card') && !e.target.closest('.detail-panel')) closeDetail(); 517 + // ── Keys ── 518 + document.addEventListener('keydown',e=>{ 519 + if(e.key==='Escape'){if(document.getElementById('graph-overlay').classList.contains('open'))closeGraph();else deselect();} 520 + if(e.key==='g'&&selId&&!document.getElementById('graph-overlay').classList.contains('open'))openGraph(); 521 + }); 522 + document.addEventListener('click',e=>{ 523 + if(!e.target.closest('.card')&&!e.target.closest('.graph-overlay')&&!e.target.closest('.header'))deselect(); 579 524 }); 525 + window.addEventListener('resize',()=>{if(selId&&mode==='focus')requestAnimationFrame(drawLines)}); 580 526 581 - renderPipeline(); 527 + render(); 582 528 </script> 583 529 </body> 584 530 </html>`;