@polycode-projects/seonix 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ // timeline.mjs — render the repo's commit timeline to a single, portable HTML
2
+ // page from a seonix graph artifact. Third sibling of the viz viewer and the
3
+ // Chronograph code browser: generated by `seonix viz` (--timeline-out, on by
4
+ // default next to --out) and served live at /timeline.html by `viz --serve`.
5
+ //
6
+ // Data comes straight from the graph artifact: Commit individuals carry the full
7
+ // sha (id), author, date, and message; each Module's `derived_from` lists the
8
+ // commits that touched it (`git:<short sha>`), which gives per-commit churn.
9
+ // Session individuals (chat sessions recorded by sessions.mjs) join the same
10
+ // timeline as `type:"session"` entries at their started timestamp — a graph with
11
+ // zero sessions renders exactly as before.
12
+
13
+ const esc = (s) =>
14
+ String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
15
+
16
+ export function extractTimeline(graph) {
17
+ const commits = [];
18
+ const sessions = [];
19
+ const churnByShort = new Map(); // short sha -> modules touched
20
+ for (const ind of graph.individuals || []) {
21
+ const attr = (key) => ind.attributes?.find((a) => a.key === key)?.value || "";
22
+ if (ind.class === "Module") {
23
+ for (const ref of ind.derived_from || []) {
24
+ const short = ref.replace(/^git:/, "");
25
+ churnByShort.set(short, (churnByShort.get(short) || 0) + 1);
26
+ }
27
+ } else if (ind.class === "Commit") {
28
+ commits.push({
29
+ sha: ind.id.replace(/^commit:/, ""),
30
+ short: ind.label,
31
+ date: attr("date"),
32
+ author: attr("author"),
33
+ message: attr("message"),
34
+ });
35
+ } else if (ind.class === "Session") {
36
+ // chat sessions (sessions.mjs) enter the timeline at their started timestamp
37
+ sessions.push({
38
+ type: "session",
39
+ id: ind.id.replace(/^session:/, ""),
40
+ short: ind.label,
41
+ date: attr("started"),
42
+ turns: Number(attr("turns")) || 0,
43
+ touched: 0,
44
+ });
45
+ }
46
+ }
47
+ for (const c of commits) c.touched = churnByShort.get(c.short) || 0;
48
+ const entries = [...commits, ...sessions];
49
+ entries.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
50
+ return entries;
51
+ }
52
+
53
+ /** `nav` is the shared {name: href} nav object the viz CLI computes from the
54
+ * actual sibling output paths — header links render ONLY from its entries, so
55
+ * a page generated without siblings never carries dead links. */
56
+ export function renderTimelineHtml({ commits, repoUrl = "", repoRef = "main", generatedAt = "", nav = null } = {}) {
57
+ const base = repoUrl.replace(/\/+$/, "");
58
+ const sessionCount = commits.filter((c) => c.type === "session").length;
59
+ const commitCount = commits.length - sessionCount;
60
+ const maxTouched = Math.max(1, ...commits.map((c) => c.touched));
61
+ // Bars run oldest→newest left to right; the list reads newest first. Chat
62
+ // sessions are the visually distinct entries (amber, fixed-height bars).
63
+ const bars = commits
64
+ .map((c, i) =>
65
+ c.type === "session"
66
+ ? `<a class="bar sess" href="#c${i}" title="chat session ${esc(c.short)} · ${esc(c.date.slice(0, 10))} — ${c.turns} turn(s)"><i style="height:10px"></i></a>`
67
+ : `<a class="bar" href="#c${i}" title="${esc(c.short)} · ${esc(c.date.slice(0, 10))} — ${esc(c.message)}"><i style="height:${Math.max(6, Math.round((c.touched / maxTouched) * 64))}px"></i></a>`,
68
+ )
69
+ .join("");
70
+ const rows = commits
71
+ .map((c, i) => {
72
+ if (c.type === "session") {
73
+ return `<li id="c${i}" class="sess"><span class="d">${esc(c.date.slice(0, 10))}</span> <code>${esc(c.short)}</code> <span class="m">chat session — ${c.turns} turn(s)</span></li>`;
74
+ }
75
+ const sha = base
76
+ ? `<a href="${base}/-/commit/${esc(c.sha)}" target="_blank" rel="noopener"><code>${esc(c.short)}</code></a>`
77
+ : `<code>${esc(c.short)}</code>`;
78
+ return `<li id="c${i}"><span class="d">${esc(c.date.slice(0, 10))}</span> ${sha} <span class="a">${esc(c.author)}</span> <span class="m">${esc(c.message)}</span> <span class="t" title="modules touched">${c.touched}⛁</span></li>`;
79
+ })
80
+ .reverse()
81
+ .join("\n");
82
+ const navLinks = nav
83
+ ? Object.entries(nav)
84
+ .map(([name, href]) => `<a href="${esc(href)}">${name === "home" ? "← home" : esc(name)}</a>`)
85
+ .join("\n ")
86
+ : "";
87
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
88
+ <title>seonix — commit timeline</title>
89
+ <style>
90
+ html,body{margin:0;font:14px/1.5 system-ui,sans-serif;background:#1a1b26;color:#c0caf5}
91
+ header{padding:14px 20px;background:#16161e;border-bottom:1px solid #2a2e42;display:flex;gap:16px;align-items:baseline;flex-wrap:wrap}
92
+ header b{color:#7aa2f7} header a{color:#7aa2f7;text-decoration:none} header a:hover{text-decoration:underline}
93
+ header .meta{color:#565f89;font-size:12px;margin-left:auto}
94
+ #bars{display:flex;align-items:flex-end;gap:3px;padding:18px 20px 6px;overflow-x:auto}
95
+ .bar i{display:block;width:9px;background:#3d59a1;border-radius:2px 2px 0 0}
96
+ .bar:hover i{background:#7aa2f7}
97
+ ol{list-style:none;margin:6px 0 40px;padding:0 20px}
98
+ li{padding:6px 8px;border-bottom:1px solid #20222f;display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}
99
+ li:target{background:#20253a;border-radius:4px}
100
+ .d{color:#565f89;font-variant-numeric:tabular-nums;white-space:nowrap}
101
+ code{color:#9ece6a} li a{text-decoration:none} li a:hover code{text-decoration:underline}
102
+ .a{color:#a9b1d6;white-space:nowrap} .m{flex:1 1 24ch} .t{color:#bb9af7;white-space:nowrap}
103
+ .bar.sess i{background:#8a6a2f;border-radius:5px} .bar.sess:hover i{background:#e0af68}
104
+ li.sess{border-left:3px solid #e0af68;padding-left:5px} li.sess code,li.sess .m{color:#e0af68}
105
+ </style></head><body>
106
+ <header>
107
+ <span><b>seonix</b> commit timeline — ${commitCount} commits${sessionCount ? ` · ${sessionCount} chat session(s)` : ""}</span>
108
+ ${navLinks}
109
+ ${base ? `<a href="${base}" target="_blank" rel="noopener">repository ↗</a>` : ""}
110
+ <span class="meta">bars = modules touched per commit · generated from the seonix graph artifact${generatedAt ? ` (${esc(generatedAt.slice(0, 10))})` : ""}</span>
111
+ </header>
112
+ <div id="bars">${bars}</div>
113
+ <ol>
114
+ ${rows}
115
+ </ol>
116
+ </body></html>`;
117
+ }
package/src/viz.mjs CHANGED
@@ -25,6 +25,7 @@ import { parseEntities, resolveSymbol, relationKind, siteOf } from "./codegraph.
25
25
  import { loadConfig } from "./config.mjs";
26
26
  import * as source from "./source.mjs";
27
27
  import { buildTemporalGraph, buildBrowserData, gitCommitOrder, gitCommitParents, renderBrowserHtml } from "./browser.mjs";
28
+ import { extractTimeline, renderTimelineHtml } from "./timeline.mjs";
28
29
 
29
30
  const here = dirname(fileURLToPath(import.meta.url));
30
31
  const execFileP = promisify(execFile);
@@ -134,7 +135,7 @@ export function buildSubgraph(graph, { focusId, depth = 2, hubDegree = 40, maxNo
134
135
 
135
136
  /** The viewer's runtime payload — everything repo-specific lives here, nothing in
136
137
  * the page. Pure; schema is part of the viewer contract (tests pin it). */
137
- export function buildViewerData(subgraph, { focusLabel, repoUrl = "", repoRef = "main", siteNav = false } = {}) {
138
+ export function buildViewerData(subgraph, { focusLabel, repoUrl = "", repoRef = "main", siteNav = false, nav = null } = {}) {
138
139
  return {
139
140
  focusId: subgraph.focusId,
140
141
  focusLabel: focusLabel || subgraph.focusId,
@@ -142,7 +143,8 @@ export function buildViewerData(subgraph, { focusLabel, repoUrl = "", repoRef =
142
143
  maxDepth: subgraph.nodes.reduce((m, n) => Math.max(m, n.depth), 0),
143
144
  repoUrl: repoUrl.replace(/\/+$/, ""),
144
145
  repoRef,
145
- siteNav: !!siteNav,
146
+ siteNav: !!siteNav, // legacy flag — stale deployed data keeps its absolute links
147
+ ...(nav ? { nav } : {}), // {name: href} — the viewer rebuilds #sitenav from this
146
148
  nodes: subgraph.nodes,
147
149
  edges: subgraph.edges,
148
150
  };
@@ -219,10 +221,10 @@ export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null,
219
221
  #bar label,.lbl{color:#a9b1d6} #bar b{color:#7aa2f7}
220
222
  #bar button{background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:2px 8px;cursor:pointer;font:inherit;line-height:1.2}
221
223
  #bar button:hover{border-color:#7aa2f7;color:#7aa2f7}
222
- .seg{display:inline-flex;border:1px solid #2a2e42;border-radius:5px;overflow:hidden}
223
- .seg .segb{border:0;border-radius:0;padding:2px 11px}
224
- .seg .segb.on{background:#7aa2f7;color:#16161e;font-weight:600}
225
- #hub{width:3.5em;background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:1px 4px;font:inherit}
224
+ #bar button:disabled{opacity:.4;cursor:default}
225
+ #bar button:disabled:hover{border-color:#2a2e42;color:#c0caf5}
226
+ #depthval{min-width:1.2em;text-align:center;display:inline-block;cursor:help}
227
+ #hub,#beam{width:3.5em;background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:1px 4px;font:inherit}
226
228
  #legend{padding:4px 12px 6px;background:#16161e;border-bottom:1px solid #2a2e42;display:flex;flex-wrap:wrap;gap:2px 12px;font-size:12px;flex:0 0 auto}
227
229
  #main{flex:1 1 auto;position:relative;min-height:0}
228
230
  #cy{position:absolute;top:0;left:0;right:300px;bottom:0}
@@ -251,14 +253,16 @@ export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null,
251
253
  </style></head><body>
252
254
  <div id="bar">
253
255
  <span class="grp"><b>seon</b> focus: <b id="focuslabel">…</b></span>
254
- <span class="grp"><span class="lbl">depth</span><span class="seg" id="depthseg"></span></span>
255
- <span class="grp"><label title="hide nodes with more connections than this (hubs swamp the layout)"><input type="checkbox" id="hubon" checked> hide hubs deg&gt; <input id="hub" type="number" min="2" max="200" value="16"></label></span>
256
+ <span class="grp"><span class="lbl">depth</span><button id="depthdown" title="shallower">−</button><b id="depthval"></b><button id="depthup" title="deeper">+</button></span>
257
+ <span class="grp"><label title="hide nodes with more connections than this (hubs swamp the layout)"><input type="checkbox" id="hubon" checked> hide hubs deg&gt; <input id="hub" type="number" min="2" max="200" value="16"></label>
258
+ <label title="beam-prune from the focus: at each hop keep only the top-N neighbours by degree (margin+cap prune) — a degree-scored analogue of the ask engine's beam search, NOT the same scorer"><input type="checkbox" id="beamon"> beam <input id="beam" type="number" min="1" max="32" value="8"></label></span>
256
259
  <span class="grp"><label>labels <select id="verb"><option value="smart">smart</option><option value="name">all names</option><option value="site">name+site</option><option value="none">none</option></select></label>
257
260
  <label>layout <select id="layout"><option value="cose">cose</option><option value="breadthfirst">tree</option><option value="concentric">concentric</option></select></label></span>
258
261
  <span class="grp" id="view">
259
- <button id="reset" title="fit the whole graph in view (Esc)">fit</button>
262
+ <button id="fit" title="fit the whole graph in view (Esc)">fit</button>
260
263
  <button id="zoomout" title="zoom out">−</button>
261
264
  <button id="zoomin" title="zoom in">+</button>
265
+ <button id="reset" title="restore the boot view: default filters, all types shown, original focus">reset</button>
262
266
  </span>
263
267
  <span class="grp nav" id="sitenav" hidden><a href="/">home</a><a href="/code-browser.html">browser</a><a href="/timeline.html">timeline</a></span>
264
268
  </div>
@@ -321,19 +325,27 @@ function loadAskData(){
321
325
  function init(G){
322
326
  document.title='seon code-map — '+G.focusLabel;
323
327
  $('focuslabel').textContent=G.focusLabel;
324
- if(G.siteNav)$('sitenav').hidden=false;
325
- // Legend doubles as a node-type filter: a checkbox chip per mgx type
326
- // (Module/Class/Function/Method/Attribute/GlobalVariable/Commit) with a live
327
- // visible/loaded count for the current depth + hub-degree filters.
328
- $('legend').innerHTML=Object.entries(COLORS)
329
- .map(([k,v])=>'<label class="lg" title="show/hide '+k+' nodes"><input type="checkbox" class="typechk" data-cls="'+k+'" checked><i style="background:'+v+'"></i>'+k+'<span class="cnt" data-cnt="'+k+'"></span></label>')
328
+ // nav rides the data payload ({name: href}, hrefs relative to THIS page);
329
+ // G.siteNav is the legacy flag stale deployed data keeps its absolute links.
330
+ if(G.nav){$('sitenav').innerHTML=Object.entries(G.nav).map(([k,h])=>'<a href="'+esc(h)+'">'+esc(k)+'</a>').join('');$('sitenav').hidden=false;}
331
+ else if(G.siteNav)$('sitenav').hidden=false;
332
+ // Legend doubles as a node-type filter: a checkbox chip per type GROUP —
333
+ // Function/Method share a chip (legend-only merge; node fills stay distinct),
334
+ // with a live visible/loaded count summed across the group.
335
+ const GROUPS=[['Module'],['Class'],['Function','Method'],['Attribute'],['GlobalVariable'],['Commit']];
336
+ $('legend').innerHTML=GROUPS
337
+ .map(g=>'<label class="lg" title="show/hide '+g.join('/')+' nodes"><input type="checkbox" class="typechk" data-cls="'+g.join(',')+'" checked>'+g.map(k=>'<i style="background:'+COLORS[k]+'"></i>').join('')+g.join('/')+'<span class="cnt" data-cnt="'+g.join(',')+'"></span></label>')
330
338
  .join(' ');
331
- // depth is a segmented control (no dead range beyond the data's max depth), default 2
332
- const maxDepth=Math.max(2,G.maxDepth);
333
- $('depthseg').innerHTML=Array.from({length:maxDepth},(_,i)=>i+1)
334
- .map(d=>'<button class="segb'+(d===2?' on':'')+'" data-d="'+d+'">'+d+'</button>').join('');
339
+ // depth is a [N] + stepper clamped to the DATA's max depth (no dead buttons), default 2
340
+ const maxDepth=Math.max(1,G.maxDepth);
341
+ let depthVal=Math.min(2,maxDepth);
342
+ $('depthval').title='captured to depth '+maxDepth+' — regenerate with --depth for more';
343
+ function syncDepthUi(){$('depthval').textContent=depthVal;$('depthdown').disabled=depthVal<=1;$('depthup').disabled=depthVal>=maxDepth;}
344
+ syncDepthUi();
345
+ const INITIAL_FOCUS=G.focusId;
335
346
  const cy=cytoscape({container:document.getElementById('cy'),
336
- elements:[...G.nodes.map(n=>({data:{...n,color:COLORS[n.cls]||'#8a8f98'}})),...G.edges.map((e,i)=>({data:{id:'e'+i,...e}}))],
347
+ // depth0 caches the boot depth per node BEFORE any recentre mutates data('depth') — the reset button restores from it
348
+ elements:[...G.nodes.map(n=>({data:{...n,depth0:n.depth,color:COLORS[n.cls]||'#8a8f98'}})),...G.edges.map((e,i)=>({data:{id:'e'+i,...e}}))],
337
349
  style:[
338
350
  // black text outline keeps white labels readable over node fills and the dark canvas;
339
351
  // node size encodes degree (bounded) so hubs read as hubs at a glance.
@@ -355,26 +367,53 @@ function init(G){
355
367
  let selId=null;
356
368
  function enabledTypes(){
357
369
  const on=new Set();
358
- document.querySelectorAll('.typechk').forEach(c=>{if(c.checked)on.add(c.dataset.cls);});
370
+ document.querySelectorAll('.typechk').forEach(c=>{if(c.checked)c.dataset.cls.split(',').forEach(t=>on.add(t));});
359
371
  return on;
360
372
  }
361
- function curDepth(){return +document.querySelector('.segb.on').dataset.d;}
373
+ function curDepth(){return depthVal;}
362
374
  function curHub(){return $('hubon').checked ? +$('hub').value : Infinity;}
375
+ const NODE=new Map(G.nodes.map(n=>[n.id,n]));
376
+ // Beam mode: BFS from the focus over the loaded adjacency, pruning each node's
377
+ // candidate neighbours (those passing hub+type filters) to the top-width by
378
+ // DEGREE with margin deg >= best*0.5 — mirrors BEAM_MARGIN_FRAC=0.5 in
379
+ // codegraph.mjs, but degree-scored: an analogue of the ask engine's beam
380
+ // search, not the same scorer.
381
+ function beamSet(width,h,types){
382
+ const pass=id=>{const n=NODE.get(id);return !!n&&(id===G.focusId||n.degree<=h)&&types.has(n.cls);};
383
+ const seen=new Set([G.focusId]);
384
+ let frontier=[G.focusId];
385
+ while(frontier.length){
386
+ const next=[];
387
+ for(const id of frontier){
388
+ const cand=(ADJ[id]||[]).filter(nb=>!seen.has(nb)&&pass(nb))
389
+ .sort((a,b)=>NODE.get(b).degree-NODE.get(a).degree);
390
+ const best=cand.length?NODE.get(cand[0]).degree:0;
391
+ for(const nb of cand.slice(0,width)){
392
+ if(NODE.get(nb).degree>=best*0.5){seen.add(nb);next.push(nb);}
393
+ }
394
+ }
395
+ frontier=next;
396
+ }
397
+ return seen;
398
+ }
363
399
  function applyFilters(){
364
400
  const d=curDepth(), h=curHub(), types=enabledTypes();
401
+ const beam=$('beamon').checked?beamSet(Math.max(1,Math.min(32,+$('beam').value||8)),h,types):null;
365
402
  const vis={}, tot={};
366
403
  cy.batch(()=>cy.nodes().forEach(n=>{
367
404
  const cls=n.data('cls');
368
405
  tot[cls]=(tot[cls]||0)+1;
369
- const v = n.data('depth')<=d && (n.id()===G.focusId || n.data('degree')<=h) && types.has(cls);
406
+ const v = beam ? beam.has(n.id())
407
+ : n.data('depth')<=d && (n.id()===G.focusId || n.data('degree')<=h) && types.has(cls);
370
408
  if(v) vis[cls]=(vis[cls]||0)+1;
371
409
  n.style('display', v?'element':'none');
372
410
  }));
373
- // live per-type counts: visible/loaded under the current depth + hub filters
411
+ // live per-group counts: visible/loaded summed across the group's classes
374
412
  document.querySelectorAll('.cnt').forEach(s=>{
375
- const k=s.dataset.cnt;
376
- s.textContent=(vis[k]||0)+'/'+(tot[k]||0);
377
- s.title=(vis[k]||0)+' visible of '+(tot[k]||0)+' loaded '+k+' node(s) at depth '+d+(isFinite(h)?', hide deg>'+h:'');
413
+ const ks=s.dataset.cnt.split(',');
414
+ const v=ks.reduce((a,k)=>a+(vis[k]||0),0), t=ks.reduce((a,k)=>a+(tot[k]||0),0);
415
+ s.textContent=v+'/'+t;
416
+ s.title=v+' visible of '+t+' loaded '+ks.join('/')+' node(s) at depth '+d+(isFinite(h)?', hide deg>'+h:'');
378
417
  });
379
418
  applyLabels();
380
419
  }
@@ -397,18 +436,39 @@ function init(G){
397
436
  }
398
437
  cy.on('mouseover','node',e=>{if($('verb').value==='smart')e.target.style('label',labelText(e.target,false));});
399
438
  cy.on('mouseout','node',e=>{if($('verb').value==='smart')applyLabels();});
400
- document.querySelectorAll('.segb').forEach(b=>b.addEventListener('click',()=>{
401
- document.querySelectorAll('.segb').forEach(x=>x.classList.remove('on'));b.classList.add('on');applyFilters();}));
439
+ const stepDepth=dir=>{const d=Math.min(maxDepth,Math.max(1,depthVal+dir));if(d===depthVal)return;depthVal=d;syncDepthUi();applyFilters();};
440
+ $('depthdown').addEventListener('click',()=>stepDepth(-1));
441
+ $('depthup').addEventListener('click',()=>stepDepth(1));
402
442
  $('hubon').addEventListener('change',applyFilters);
403
443
  $('hub').addEventListener('input',applyFilters);
444
+ $('beamon').addEventListener('change',applyFilters);
445
+ $('beam').addEventListener('input',applyFilters);
404
446
  document.querySelectorAll('.typechk').forEach(c=>c.addEventListener('change',applyFilters));
405
447
  $('verb').addEventListener('change',applyLabels);
406
448
  $('layout').addEventListener('change',()=>cy.layout({name:$('layout').value,animate:false}).run());
407
449
  const ZF=1.2;
408
450
  const zoomBy=(f)=>cy.zoom({level:cy.zoom()*f,renderedPosition:{x:cy.width()/2,y:cy.height()/2}});
409
- $('reset').addEventListener('click',()=>{cy.fit(undefined,30);});
451
+ $('fit').addEventListener('click',()=>{cy.fit(undefined,30);});
410
452
  $('zoomin').addEventListener('click',()=>zoomBy(ZF));
411
453
  $('zoomout').addEventListener('click',()=>zoomBy(1/ZF));
454
+ // real reset: back to the boot view — defaults, all types on, depths from
455
+ // depth0 (the pre-recentre capture), focus ring on the boot focus.
456
+ $('reset').addEventListener('click',()=>{
457
+ if($('layout').value!=='cose'){$('layout').value='cose';cy.layout({name:'cose',animate:false}).run();}
458
+ $('verb').value='smart';
459
+ $('hubon').checked=true;$('hub').value=16;
460
+ $('beamon').checked=false;$('beam').value=8;
461
+ depthVal=Math.min(2,maxDepth);syncDepthUi();
462
+ document.querySelectorAll('.typechk').forEach(c=>{c.checked=true;});
463
+ cy.getElementById(G.focusId).removeClass('focus');
464
+ G.focusId=INITIAL_FOCUS;
465
+ cy.batch(()=>cy.nodes().forEach(n=>n.data('depth',n.data('depth0'))));
466
+ cy.getElementById(INITIAL_FOCUS).addClass('focus');
467
+ $('focuslabel').textContent=G.focusLabel;
468
+ applyFilters();
469
+ select(null);
470
+ cy.fit(undefined,30);
471
+ });
412
472
  // Deep link into the source host (GitLab URL layout) when the data carries a
413
473
  // repoUrl: Commit nodes -> /-/commit/<sha>, anything with a file site ->
414
474
  // /-/blob/<ref>/<path>#L<lines>, bare Modules -> the file blob.
@@ -592,6 +652,8 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
592
652
  const repoUrl = values["repo-url"] || (await repoUrlFromGit(process.cwd()));
593
653
  const viewer = renderViewerHtml({ cytoscape, askEngine });
594
654
  const browserPage = await renderBrowserHtml({ cytoscape });
655
+ // nav = the routes this server itself serves; injected into every payload/page
656
+ const nav = { home: "/", graph: "/seonix-graph.html", browser: "/code-browser.html", timeline: "/timeline.html" };
595
657
  const buildData = async () => {
596
658
  const { graph } = await loadGraph(values);
597
659
  const focus = resolveFocus(graph, values.focus);
@@ -602,7 +664,7 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
602
664
  hubDegree: Math.max(2, parseInt(values.hub, 10) || 40),
603
665
  maxNodes: Math.max(2, parseInt(values.max, 10) || 200),
604
666
  });
605
- return buildViewerData(subgraph, { focusLabel: focus.label, repoUrl, repoRef: values.ref });
667
+ return buildViewerData(subgraph, { focusLabel: focus.label, repoUrl, repoRef: values.ref, nav });
606
668
  };
607
669
  // The chat panel's own channel — the FULL raw graph (not the depth-limited display
608
670
  // sub-graph above), rebuilt fresh per request so a re-index shows up without a
@@ -620,7 +682,17 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
620
682
  gitCommitParents(process.cwd()), // P3 ghost-branch merges
621
683
  ]);
622
684
  const tg = buildTemporalGraph(raw, order, { scope: values.scope, parentsBySha });
623
- return buildBrowserData(tg, { repoUrl, repoRef: values.ref, live: true, gitHead: await gitHeadOf(process.cwd()) });
685
+ return buildBrowserData(tg, { repoUrl, repoRef: values.ref, live: true, gitHead: await gitHeadOf(process.cwd()), nav });
686
+ };
687
+ const buildTimelinePage = async () => {
688
+ const { raw } = await loadGraph(values);
689
+ return renderTimelineHtml({
690
+ commits: extractTimeline(raw),
691
+ repoUrl,
692
+ repoRef: values.ref,
693
+ generatedAt: raw.generated_at || "",
694
+ nav,
695
+ });
624
696
  };
625
697
  await buildData(); // fail fast (missing graph, bad focus) before binding the port
626
698
  const server = createServer(async (req, res) => {
@@ -631,6 +703,16 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
631
703
  } else if (path === "/browser" || path === "/code-browser.html") {
632
704
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
633
705
  res.end(browserPage);
706
+ } else if (path === "/timeline" || path === "/timeline.html") {
707
+ // rendered from a fresh graph load, same re-index-shows-on-refresh invariant
708
+ try {
709
+ const page = await buildTimelinePage();
710
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
711
+ res.end(page);
712
+ } catch (err) {
713
+ res.writeHead(500, { "content-type": "text/plain" });
714
+ res.end(`failed to render the timeline: ${String(err.message || err)}`);
715
+ }
634
716
  } else if (path === "/seonix-graph-data.json") {
635
717
  try {
636
718
  res.writeHead(200, { "content-type": "application/json" });
@@ -693,9 +775,12 @@ export async function runVizCli(argv) {
693
775
  "site-nav": { type: "boolean", default: false },
694
776
  serve: { type: "boolean", default: false },
695
777
  port: { type: "string", default: "0" },
696
- // Chronograph (code browser) artifacts — see src/browser.mjs
778
+ // Chronograph (code browser) + timeline artifacts — generated next to --out
779
+ // by default; --graph-only suppresses both. See src/browser.mjs, src/timeline.mjs.
697
780
  "browser-out": { type: "string" },
698
781
  "browser-data-out": { type: "string" },
782
+ "timeline-out": { type: "string" },
783
+ "graph-only": { type: "boolean", default: false },
699
784
  scope: { type: "string", default: "product" },
700
785
  },
701
786
  allowPositionals: false,
@@ -721,13 +806,30 @@ export async function runVizCli(argv) {
721
806
  hubDegree: Math.max(2, parseInt(values.hub, 10) || 40),
722
807
  maxNodes: Math.max(2, parseInt(values.max, 10) || 200),
723
808
  });
809
+ const out = resolve(values.out);
810
+ // Siblings by default: the code browser and the commit timeline land next to
811
+ // --out unless --graph-only. Nav hrefs are computed from the ACTUAL output
812
+ // paths (filenames vary: seon-graph.html locally, seonix-graph.html on site);
813
+ // --site-nav additionally adds the site's absolute home entry.
814
+ const browserOut = values["graph-only"] ? null : resolve(values["browser-out"] || join(dirname(out), "code-browser.html"));
815
+ const timelineOut = values["graph-only"] ? null : resolve(values["timeline-out"] || join(dirname(out), "timeline.html"));
816
+ const relHref = (from, to) => {
817
+ const r = relative(dirname(from), to);
818
+ return r.startsWith(".") ? r : `./${r}`;
819
+ };
820
+ const navFor = (from) => ({
821
+ ...(values["site-nav"] ? { home: "/" } : {}),
822
+ graph: relHref(from, out),
823
+ browser: relHref(from, browserOut),
824
+ timeline: relHref(from, timelineOut),
825
+ });
724
826
  const data = buildViewerData(subgraph, {
725
827
  focusLabel: focus.label,
726
828
  repoUrl: values["repo-url"],
727
829
  repoRef: values.ref,
728
830
  siteNav: values["site-nav"],
831
+ nav: values["graph-only"] ? null : navFor(out),
729
832
  });
730
- const out = resolve(values.out);
731
833
  if (values["data-out"]) {
732
834
  // site mode: viewer page + sibling data file (data updates don't touch the page).
733
835
  // The chat panel's full-graph channel rides the same sibling-file convention,
@@ -755,36 +857,55 @@ export async function runVizCli(argv) {
755
857
  );
756
858
  }
757
859
 
758
- // Chronograph artifacts (the code browser) — same one-page/data-sibling pattern.
759
- if (values["browser-out"]) {
760
- const raw = await source.fetchEntities(config);
761
- const commitIds = (raw.individuals || []).filter((i) => i.class === "Commit").map((i) => i.id);
762
- const [order, parentsBySha] = await Promise.all([
763
- gitCommitOrder(process.cwd(), commitIds),
764
- gitCommitParents(process.cwd()), // P3 ghost-branch merges — static data, no polling here
765
- ]);
766
- const tg = buildTemporalGraph(raw, order, { scope: values.scope, parentsBySha });
767
- const browserData = buildBrowserData(tg, {
768
- repoUrl: values["repo-url"] || (await repoUrlFromGit(process.cwd())),
769
- repoRef: values.ref,
770
- siteNav: values["site-nav"],
771
- });
772
- const bOut = resolve(values["browser-out"]);
773
- if (values["browser-data-out"]) {
774
- const bDataOut = resolve(values["browser-data-out"]);
775
- const rel = relative(dirname(bOut), bDataOut) || "code-browser-data.json";
776
- await writeFile(bDataOut, JSON.stringify(browserData));
777
- await writeFile(bOut, await renderBrowserHtml({ cytoscape, dataPath: rel === "code-browser-data.json" ? null : rel }));
778
- process.stderr.write(
779
- `seonix viz: chronograph ${tg.nodes.length} nodes, ${tg.edges.length} edges, ${tg.commits.length} commits ` +
780
- `(scope ${values.scope}) → ${bOut} + ${bDataOut}\n`,
781
- );
782
- } else {
783
- await writeFile(bOut, await renderBrowserHtml({ cytoscape, embedData: browserData }));
784
- process.stderr.write(
785
- `seonix viz: chronograph ${tg.nodes.length} nodes, ${tg.edges.length} edges, ${tg.commits.length} commits ` +
786
- `(scope ${values.scope}) ${bOut}\n`,
787
- );
860
+ // Sibling artifacts (code browser + commit timeline) — best-effort: a missing
861
+ // git history (or any other failure here) warns but never kills the graph
862
+ // artifact already written above.
863
+ if (browserOut || timelineOut) {
864
+ try {
865
+ const repoUrl = values["repo-url"] || (await repoUrlFromGit(process.cwd()));
866
+ if (browserOut) {
867
+ const commitIds = (raw.individuals || []).filter((i) => i.class === "Commit").map((i) => i.id);
868
+ const [order, parentsBySha] = await Promise.all([
869
+ gitCommitOrder(process.cwd(), commitIds),
870
+ gitCommitParents(process.cwd()), // P3 ghost-branch merges — static data, no polling here
871
+ ]);
872
+ const tg = buildTemporalGraph(raw, order, { scope: values.scope, parentsBySha });
873
+ const browserData = buildBrowserData(tg, {
874
+ repoUrl,
875
+ repoRef: values.ref,
876
+ siteNav: values["site-nav"],
877
+ nav: navFor(browserOut),
878
+ });
879
+ if (values["browser-data-out"]) {
880
+ const bDataOut = resolve(values["browser-data-out"]);
881
+ const rel = relative(dirname(browserOut), bDataOut) || "code-browser-data.json";
882
+ await writeFile(bDataOut, JSON.stringify(browserData));
883
+ await writeFile(browserOut, await renderBrowserHtml({ cytoscape, dataPath: rel === "code-browser-data.json" ? null : rel }));
884
+ process.stderr.write(
885
+ `seonix viz: chronograph ${tg.nodes.length} nodes, ${tg.edges.length} edges, ${tg.commits.length} commits ` +
886
+ `(scope ${values.scope}) → ${browserOut} + ${bDataOut}\n`,
887
+ );
888
+ } else {
889
+ await writeFile(browserOut, await renderBrowserHtml({ cytoscape, embedData: browserData }));
890
+ process.stderr.write(
891
+ `seonix viz: chronograph ${tg.nodes.length} nodes, ${tg.edges.length} edges, ${tg.commits.length} commits ` +
892
+ `(scope ${values.scope}) → ${browserOut}\n`,
893
+ );
894
+ }
895
+ }
896
+ if (timelineOut) {
897
+ const commits = extractTimeline(raw);
898
+ await writeFile(timelineOut, renderTimelineHtml({
899
+ commits,
900
+ repoUrl,
901
+ repoRef: values.ref,
902
+ generatedAt: raw.generated_at || "",
903
+ nav: navFor(timelineOut),
904
+ }));
905
+ process.stderr.write(`seonix viz: timeline ${commits.length} commits → ${timelineOut}\n`);
906
+ }
907
+ } catch (err) {
908
+ process.stderr.write(`seonix viz: warning — sibling pages (browser/timeline) failed: ${String(err.message || err)}; the graph artifact is intact\n`);
788
909
  }
789
910
  }
790
911
  }