@polycode-projects/seonix 0.1.0 → 0.2.1

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,769 @@
1
+ // browser.mjs — Chronograph, the temporal code browser (PLAN_CODE_BROWSER.md), on
2
+ // the unified viewer architecture: ONE generated page, data loaded at runtime, the
3
+ // same page served by the website and by `seonix viz --serve` against a local index.
4
+ //
5
+ // Node-side: buildTemporalGraph (graph.json + git-log order → validity intervals),
6
+ // buildBrowserData (the runtime payload), renderBrowserHtml (the page). The page's
7
+ // scrub/diff/narration/query/URL logic is NOT duplicated here — renderBrowserHtml
8
+ // inlines the SOURCE of ../src/temporal.mjs (exports stripped), so the browser runs
9
+ // the exact functions `node --test` verifies. chronograph/build.mjs (legacy P0) is a
10
+ // thin wrapper over the same builder.
11
+
12
+ import { readFile } from "node:fs/promises";
13
+ import { execFile } from "node:child_process";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { promisify } from "node:util";
17
+ import { deriveNodeInterval, deriveEdgeInterval } from "./temporal.mjs";
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const execFileP = promisify(execFile);
21
+
22
+ // Structural edge kinds kept in the temporal graph (module-coarse "touches" drops
23
+ // as a duplicate of symbol-level touchesSymbol history).
24
+ const STRUCT_KINDS = new Set([
25
+ "defines", "callsSymbol", "calls", "imports",
26
+ "reexports", "inherits", "contains", "cochange", "tests",
27
+ ]);
28
+
29
+ // Default scope: hand-written product code, minus corpus/build/vendor noise.
30
+ const PRODUCT_EXCLUDE = [
31
+ "corpus/", "node_modules/", "vendor/", ".venv/",
32
+ "infra/", "archive/", ".seonix/",
33
+ ];
34
+
35
+ // SEON/mgx prop token -> edge kind (mirrors codegraph.mjs's vocabulary).
36
+ const PROP_KIND = {
37
+ "mgx:importsnamespace": "imports",
38
+ "mgx:callscoarse": "calls",
39
+ "seon:declaresmethod": "defines",
40
+ "mgx:testscoverage": "tests",
41
+ "mgx:touchedbycommit": "touches",
42
+ "seon:containscodeentity": "contains",
43
+ "seon:hassupertype": "inherits",
44
+ "mgx:changecoupledwith": "cochange",
45
+ "mgx:reexports": "reexports",
46
+ "mgx:touchessymbol": "touchesSymbol",
47
+ "mgx:callssymbol": "callsSymbol",
48
+ };
49
+
50
+ const pathOfId = (id) => {
51
+ const m = String(id).match(/^[a-z]+:([^#]+)/);
52
+ return m ? m[1] : null;
53
+ };
54
+
55
+ function makeScopeFilter(scopeArg) {
56
+ if (!scopeArg || scopeArg === "product") {
57
+ return (id) => {
58
+ const p = pathOfId(id);
59
+ if (!p) return false;
60
+ if (p.includes("cdk.out")) return false;
61
+ return !PRODUCT_EXCLUDE.some((x) => p.startsWith(x));
62
+ };
63
+ }
64
+ const prefixes = scopeArg.split(",").map((s) => s.trim()).filter(Boolean);
65
+ return (id) => {
66
+ const p = pathOfId(id);
67
+ return p != null && prefixes.some((pre) => p.startsWith(pre));
68
+ };
69
+ }
70
+
71
+ function kindOf(group) {
72
+ const prop = String(group?.prop || "").toLowerCase();
73
+ if (PROP_KIND[prop]) return PROP_KIND[prop];
74
+ return String(group?.predicate || "") || null;
75
+ }
76
+
77
+ const attr = (ind, key) => {
78
+ const a = (ind?.attributes || []).find((x) => x.key === key);
79
+ return a ? String(a.value) : "";
80
+ };
81
+
82
+ /** Ordered (oldest → newest) shas for the graph's Commit individuals via git log. */
83
+ export async function gitCommitOrder(repo, graphCommitIds) {
84
+ const shas = new Set([...graphCommitIds].map((id) => String(id).replace(/^commit:/, "")));
85
+ let ordered = [];
86
+ try {
87
+ const { stdout } = await execFileP("git", ["-C", repo, "log", "--format=%H"], {
88
+ maxBuffer: 64 * 1024 * 1024,
89
+ });
90
+ ordered = stdout.trim().split("\n").reverse().filter((s) => shas.has(s));
91
+ } catch {
92
+ /* fall through to graph order */
93
+ }
94
+ if (!ordered.length) ordered = [...shas];
95
+ return ordered;
96
+ }
97
+
98
+ /**
99
+ * Parent shas of every commit reachable from HEAD (P3 ghost-branch merges —
100
+ * PLAN_CODE_BROWSER.md "follow-one-branch, ghost merges"). A commit with ≥2
101
+ * parents is a merge; parents beyond the first are the tip of whatever branch
102
+ * got folded in. Best-effort: an empty Map on any git failure, so callers
103
+ * degrade to "no merge info" the same way a HEAD-only index already degrades.
104
+ * @param {string} repo
105
+ * @returns {Promise<Map<string, string[]>>} sha -> parent shas, git's own order
106
+ * (a root commit maps to []).
107
+ */
108
+ export async function gitCommitParents(repo) {
109
+ const parents = new Map();
110
+ try {
111
+ const { stdout } = await execFileP("git", ["-C", repo, "log", "--format=%H %P"], {
112
+ maxBuffer: 64 * 1024 * 1024,
113
+ });
114
+ for (const line of stdout.trim().split("\n")) {
115
+ if (!line) continue;
116
+ const [sha, ...ps] = line.split(" ").filter(Boolean);
117
+ if (sha) parents.set(sha, ps);
118
+ }
119
+ } catch {
120
+ /* empty map — merge info is best-effort, never fatal */
121
+ }
122
+ return parents;
123
+ }
124
+
125
+ /**
126
+ * RAW graph.json (+ an oldest-first sha order) → the temporal graph the browser
127
+ * scrubs. Pure given its inputs; the one source for chronograph/build.mjs, the
128
+ * website artifact, and `viz --serve`'s live payload.
129
+ * @param {object} raw parsed .seonix/graph.json
130
+ * @param {string[]} orderShas oldest-first commit shas (see gitCommitOrder)
131
+ * @param {{scope?: string, parentsBySha?: Map<string,string[]>}} [opts]
132
+ */
133
+ export function buildTemporalGraph(raw, orderShas, { scope = "product", parentsBySha = new Map() } = {}) {
134
+ const individuals = Array.isArray(raw.individuals) ? raw.individuals : [];
135
+ const groups = Array.isArray(raw.objectProperties) ? raw.objectProperties : [];
136
+ const inScope = makeScopeFilter(scope);
137
+
138
+ const commitInds = individuals.filter((i) => i.class === "Commit");
139
+ const commitById = new Map(commitInds.map((c) => [c.id, c]));
140
+ const order = (orderShas && orderShas.length)
141
+ ? orderShas
142
+ : commitInds.map((c) => String(c.id).replace(/^commit:/, ""));
143
+ const ordinalOf = new Map(order.map((sha, i) => [`commit:${sha}`, i]));
144
+ const headIdx = Math.max(0, order.length - 1);
145
+ const commits = order.map((sha, i) => {
146
+ const c = commitById.get(`commit:${sha}`);
147
+ // P3 ghost-branch merges: parents present in `order` become jump-able
148
+ // ordinals; parents NOT present (their commits never touched a tracked
149
+ // symbol) are a "ghost" tributary — counted, not dropped, so a merge
150
+ // still reads as a merge even when its side-branch is otherwise invisible.
151
+ const parentIdx = [];
152
+ let ghostParents = 0;
153
+ for (const p of (parentsBySha.get(sha) || [])) {
154
+ const pi = ordinalOf.get(`commit:${p}`);
155
+ if (pi != null) parentIdx.push(pi);
156
+ else ghostParents += 1;
157
+ }
158
+ return {
159
+ idx: i,
160
+ sha,
161
+ shortSha: sha.slice(0, 12),
162
+ date: c ? attr(c, "date") : "",
163
+ author: c ? attr(c, "author") : "",
164
+ subject: c ? attr(c, "message") : "",
165
+ parentIdx,
166
+ ghostParents,
167
+ merge: parentIdx.length + ghostParents > 1,
168
+ };
169
+ });
170
+
171
+ const symInds = individuals.filter((i) => i.class !== "Commit" && inScope(i.id));
172
+ const nodeIds = new Set(symInds.map((i) => i.id));
173
+
174
+ const touchesByNode = new Map();
175
+ const tsEdges = [];
176
+ for (const g of groups) {
177
+ if (kindOf(g) !== "touchesSymbol") continue;
178
+ for (const e of g.examples || []) {
179
+ const ord = ordinalOf.get(e.subject);
180
+ if (ord == null || !nodeIds.has(e.object)) continue;
181
+ if (!touchesByNode.has(e.object)) touchesByNode.set(e.object, new Set());
182
+ touchesByNode.get(e.object).add(ord);
183
+ tsEdges.push({ src: e.subject, dst: e.object, ord });
184
+ }
185
+ }
186
+
187
+ // Container chain (defines/contains) gives history-less symbols a birth fallback.
188
+ const parentOf = new Map();
189
+ for (const g of groups) {
190
+ const k = kindOf(g);
191
+ if (k !== "defines" && k !== "contains") continue;
192
+ for (const e of g.examples || []) {
193
+ if (nodeIds.has(e.subject) && nodeIds.has(e.object) && !parentOf.has(e.object)) {
194
+ parentOf.set(e.object, e.subject);
195
+ }
196
+ }
197
+ }
198
+ // Roll symbol history UP the container chain (P2): a module is touched when any
199
+ // of its symbols is. touchesSymbol edges land on symbols, so without this roll-up
200
+ // module-grain gravity, heat, and the touched:/since: query terms are blind —
201
+ // observed on the real self-index: 109 module↔module cochange edges, 0 modules
202
+ // with direct touches.
203
+ for (const [id, set] of [...touchesByNode]) {
204
+ let cur = parentOf.get(id);
205
+ const seen = new Set([id]);
206
+ while (cur && !seen.has(cur)) {
207
+ if (!touchesByNode.has(cur)) touchesByNode.set(cur, new Set());
208
+ for (const o of set) touchesByNode.get(cur).add(o);
209
+ seen.add(cur);
210
+ cur = parentOf.get(cur);
211
+ }
212
+ }
213
+ const directBorn = (id) => {
214
+ const s = touchesByNode.get(id);
215
+ return s && s.size ? Math.min(...s) : null;
216
+ };
217
+ const fallbackBorn = (id) => {
218
+ let cur = parentOf.get(id);
219
+ const seen = new Set([id]);
220
+ while (cur && !seen.has(cur)) {
221
+ const b = directBorn(cur);
222
+ if (b != null) return b;
223
+ seen.add(cur);
224
+ cur = parentOf.get(cur);
225
+ }
226
+ return 0;
227
+ };
228
+
229
+ const bornOf = new Map();
230
+ const nodes = [];
231
+ for (const ind of symInds) {
232
+ const touches = [...(touchesByNode.get(ind.id) || [])].sort((a, b) => a - b);
233
+ const [born, died] = deriveNodeInterval(touches, headIdx, fallbackBorn(ind.id));
234
+ bornOf.set(ind.id, born);
235
+ nodes.push({
236
+ id: ind.id,
237
+ type: ind.class,
238
+ label: ind.label || ind.id,
239
+ site: attr(ind, "site"),
240
+ born,
241
+ died,
242
+ touches,
243
+ churn: touches.length,
244
+ });
245
+ }
246
+ for (const c of commits) {
247
+ nodes.push({
248
+ id: `commit:${c.sha}`, type: "Commit", label: c.shortSha, site: "",
249
+ born: c.idx, died: headIdx, touches: [], churn: 0, commitIdx: c.idx,
250
+ });
251
+ bornOf.set(`commit:${c.sha}`, c.idx);
252
+ }
253
+
254
+ const edges = [];
255
+ const seen = new Set();
256
+ for (const g of groups) {
257
+ const kind = kindOf(g);
258
+ if (!STRUCT_KINDS.has(kind)) continue;
259
+ for (const e of g.examples || []) {
260
+ if (!nodeIds.has(e.subject) || !nodeIds.has(e.object)) continue;
261
+ const key = `${e.subject}|${e.object}|${kind}`;
262
+ if (seen.has(key)) continue;
263
+ seen.add(key);
264
+ edges.push({
265
+ src: e.subject,
266
+ dst: e.object,
267
+ kind,
268
+ valid: deriveEdgeInterval(bornOf.get(e.subject) ?? 0, bornOf.get(e.object) ?? 0, headIdx),
269
+ });
270
+ }
271
+ }
272
+ const seenTs = new Set();
273
+ for (const e of tsEdges) {
274
+ const key = `${e.src}|${e.dst}`;
275
+ if (seenTs.has(key)) continue;
276
+ seenTs.add(key);
277
+ edges.push({ src: e.src, dst: e.dst, kind: "touchesSymbol", valid: [e.ord, headIdx] });
278
+ }
279
+
280
+ // Degree over structural edges only — the declutter filter's input.
281
+ const degree = new Map();
282
+ for (const e of edges) {
283
+ if (e.kind === "touchesSymbol") continue;
284
+ degree.set(e.src, (degree.get(e.src) || 0) + 1);
285
+ degree.set(e.dst, (degree.get(e.dst) || 0) + 1);
286
+ }
287
+ for (const n of nodes) n.degree = degree.get(n.id) || 0;
288
+
289
+ return {
290
+ meta: {
291
+ scope,
292
+ commit_order: "oldest-first (ordinal 0 = oldest, N-1 = HEAD)",
293
+ note: "died = HEAD for every node: a HEAD-only index cannot observe deletions.",
294
+ counts: { commits: commits.length, nodes: nodes.length, edges: edges.length, touchesSymbol_edges: tsEdges.length },
295
+ },
296
+ commits,
297
+ nodes,
298
+ edges,
299
+ };
300
+ }
301
+
302
+ /** The browser's runtime payload: the temporal graph + everything repo-specific.
303
+ * P3 live re-annotate: `live` + `gitHead` are set ONLY by `viz --serve` (never by
304
+ * the static site generator); the client polls `/code-browser-version` and — since
305
+ * the page's whole view state already round-trips through the URL (P1) — a plain
306
+ * reload on HEAD change is enough to pick up a re-index without losing the cursor,
307
+ * selection, or query. */
308
+ export function buildBrowserData(tg, { repoUrl = "", repoRef = "main", siteNav = false, live = false, gitHead = "" } = {}) {
309
+ return { ...tg, repoUrl: String(repoUrl).replace(/\/+$/, ""), repoRef, siteNav: !!siteNav, live: !!live, gitHead };
310
+ }
311
+
312
+ /** The tested temporal core, ready to inline into the page (exports stripped). */
313
+ export async function temporalSource() {
314
+ const src = await readFile(join(here, "temporal.mjs"), "utf8");
315
+ return src.replace(/^export /gm, "");
316
+ }
317
+
318
+ const inlineJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
319
+
320
+ /**
321
+ * The ONE Chronograph page. Repo-agnostic like the viz viewer: data resolves from
322
+ * `?data=` → embedded block → generated pointer → sibling ./code-browser-data.json.
323
+ * `temporal` is the inlined source from temporalSource().
324
+ */
325
+ export function renderBrowserPage({ cytoscape, temporal, embedData = null, dataPath = null } = {}) {
326
+ const embedded = embedData ? `<script type="application/json" id="seonix-data">${inlineJson(embedData)}</script>\n` : "";
327
+ const cfg = dataPath ? `<script type="application/json" id="seonix-cfg">${inlineJson({ data: dataPath })}</script>\n` : "";
328
+ return `<!doctype html><html><head><meta charset="utf-8"><title>seon chronograph</title>
329
+ <!-- generated by: seonix viz --browser-out (packages/seonix/src/browser.mjs) — one page, data at runtime;
330
+ the scrub/diff/narration/query logic below is the inlined source of src/temporal.mjs (node-tested). -->
331
+ <style>
332
+ html,body{margin:0;height:100%;font:13px system-ui,sans-serif;background:#1a1b26;color:#c0caf5}
333
+ body{display:flex;flex-direction:column}
334
+ #bar{padding:6px 12px;background:#16161e;border-bottom:1px solid #2a2e42;display:flex;gap:8px;align-items:center;flex-wrap:wrap;flex:0 0 auto}
335
+ #bar .grp{display:inline-flex;gap:8px;align-items:center;padding:0 10px;border-right:1px solid #2a2e42}
336
+ #bar .grp:first-child{padding-left:0} #bar .grp:last-of-type,#bar .nav{border-right:0}
337
+ #bar .nav{margin-left:auto} #bar .nav a{color:#7aa2f7;text-decoration:none;font-size:12px} #bar .nav a:hover{text-decoration:underline}
338
+ #bar label,.lbl{color:#a9b1d6} #bar b{color:#7aa2f7}
339
+ #bar button{background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:2px 8px;cursor:pointer;font:inherit;line-height:1.2}
340
+ #bar button:hover{border-color:#7aa2f7;color:#7aa2f7}
341
+ #bar button.on{background:#7aa2f7;color:#16161e;font-weight:600}
342
+ #q{width:15em;background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:2px 6px;font:inherit}
343
+ #deg{width:3.2em;background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:1px 4px;font:inherit}
344
+ #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}
345
+ .lg{white-space:nowrap;cursor:pointer;user-select:none;color:#a9b1d6}.lg i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:4px;vertical-align:middle}.lg input{vertical-align:middle;margin:0 3px 0 0}
346
+ .lg .cnt{color:#565f89;margin-left:3px;font-size:11px;font-variant-numeric:tabular-nums}
347
+ #difflegend{display:none;gap:12px;font-size:12px;color:#a9b1d6;align-items:center}
348
+ #difflegend i{display:inline-block;width:10px;height:10px;border-radius:50%;border:3px solid;background:transparent;margin-right:4px;vertical-align:middle}
349
+ #main{flex:1 1 auto;position:relative;min-height:0}
350
+ #cy{position:absolute;top:0;left:0;right:320px;bottom:0}
351
+ #side{position:absolute;top:0;right:0;width:320px;bottom:0;background:#16161e;border-left:1px solid #2a2e42;padding:14px;overflow:auto;box-sizing:border-box}
352
+ #tl{flex:0 0 auto;background:#16161e;border-top:1px solid #2a2e42;padding:6px 12px 8px}
353
+ #tlcanvas{width:100%;height:56px;display:block;cursor:crosshair}
354
+ #tlmeta{font-size:12px;color:#a9b1d6;margin-top:4px;min-height:1.2em}
355
+ #narr{white-space:pre-wrap;background:#1a1b26;border:1px solid #2a2e42;border-radius:6px;padding:10px;font-size:12px;line-height:1.55;color:#c0caf5;margin:0 0 12px}
356
+ #narr b{color:#7aa2f7}
357
+ #qnote{display:none;color:#e0af68;font-size:12px;margin:0 0 8px}
358
+ #side h3{margin:2px 0 8px;color:#c0caf5;font-size:15px;word-break:break-all}
359
+ #side h4{margin:12px 0 4px;color:#a9b1d6;font-size:12px;text-transform:uppercase;letter-spacing:.04em}
360
+ #side ul{margin:4px 0;padding-left:18px;font-size:12px} #side li{margin:2px 0}
361
+ .badge{display:inline-flex;align-items:center;gap:6px;padding:2px 9px;border:1px solid #2a2e42;border-radius:10px;font-size:11px;color:#a9b1d6}
362
+ .badge i{width:8px;height:8px;border-radius:50%;display:inline-block}
363
+ .btn{display:inline-block;padding:4px 10px;border:1px solid #3b4261;border-radius:5px;color:#7aa2f7;background:#1a1b26;cursor:pointer;text-decoration:none;font:inherit;font-size:12px}
364
+ .btn:hover{border-color:#7aa2f7}
365
+ .row{display:flex;gap:8px;margin:10px 0;flex-wrap:wrap}
366
+ .hint{color:#565f89;font-size:11px;margin-top:10px}
367
+ .empty{color:#a9b1d6;font-size:12px;line-height:1.7}
368
+ code{color:#9aa5ce;word-break:break-all}
369
+ dialog{background:#16161e;color:#c0caf5;border:1px solid #2a2e42;border-radius:8px;padding:16px 20px;max-width:26em}
370
+ dialog::backdrop{background:rgba(0,0,0,.5)}
371
+ dialog h2{margin:0 0 10px;font-size:14px;color:#7aa2f7}
372
+ dialog table{border-collapse:collapse;font-size:12px}
373
+ dialog td{padding:2px 10px 2px 0;vertical-align:top}
374
+ kbd{background:#1a1b26;border:1px solid #3b4261;border-radius:3px;padding:0 5px;font:inherit;color:#c0caf5}
375
+ dialog .row{margin-top:12px}
376
+ </style></head><body>
377
+ <div id="bar">
378
+ <span class="grp"><b>seon</b> chronograph <span class="lbl" id="scopelabel"></span></span>
379
+ <span class="grp"><input id="q" type="search" placeholder="query — e.g. type:Class render" title="whitespace-ANDed terms: type:Class · touched:>N · since:<sha> · cochange:<name> · free words substring-match name/path"></span>
380
+ <span class="grp"><button id="cmp" title="two-cursor structural diff: pin A, move B">compare</button>
381
+ <span id="abpick" style="display:none"><button id="pickA" class="on" title="clicks move cursor A">A</button><button id="pickB" title="clicks move cursor B">B</button></span></span>
382
+ <span class="grp"><button id="grav" title="co-change gravity: symbols that historically change together pull into clusters; the layout re-runs when the cursor settles">gravity</button><button id="heat" title="hotspot heat: recency-weighted churn as a warm halo (paused in compare mode — the diff owns attention)">heat</button></span>
383
+ <span class="grp"><button id="play">▶ play</button></span>
384
+ <span class="grp"><label title="hide symbols with fewer structural edges than this">declutter deg≥ <input id="deg" type="number" min="0" max="99" value="2"></label></span>
385
+ <span class="grp"><button id="reset" title="fit (Esc)">fit</button><button id="zoomout">−</button><button id="zoomin">+</button><button id="help" title="keyboard shortcuts (?)" aria-haspopup="dialog">?</button></span>
386
+ <span class="grp nav" id="sitenav" hidden><a href="/">home</a><a href="/seonix-graph.html">graph</a><a href="/timeline.html">timeline</a></span>
387
+ </div>
388
+ <div id="legend"></div>
389
+ <div id="legend2" style="padding:0 12px 6px;background:#16161e;border-bottom:1px solid #2a2e42"><span id="difflegend"><i style="border-color:#4fd67a"></i>added <i style="border-color:#e0af68"></i>changed/rewired <i style="border-color:#f7768e"></i>removed</span></div>
390
+ <div id="main">
391
+ <div id="cy" tabindex="0" aria-label="code graph — press Enter to select the highest-degree visible symbol, ? for the full key map"></div>
392
+ <div id="side"><p id="qnote"></p><pre id="narr"></pre><div id="detail" tabindex="-1"></div></div>
393
+ </div>
394
+ <div id="tl"><canvas id="tlcanvas"></canvas><div id="tlmeta"></div></div>
395
+ <dialog id="helpdlg" aria-label="keyboard shortcuts">
396
+ <h2>keyboard shortcuts</h2>
397
+ <table>
398
+ <tr><td><kbd>←</kbd> <kbd>→</kbd></td><td>move the cursor (active cursor in compare mode)</td></tr>
399
+ <tr><td><kbd>space</kbd></td><td>play / pause the scrub</td></tr>
400
+ <tr><td><kbd>Enter</kbd></td><td>select a node (highest-degree visible, if none selected) and open its biography</td></tr>
401
+ <tr><td><kbd>]</kbd></td><td>jump to the next outgoing neighbour of the selected node</td></tr>
402
+ <tr><td><kbd>[</kbd></td><td>jump to the next incoming neighbour of the selected node</td></tr>
403
+ <tr><td><kbd>Esc</kbd></td><td>close this dialog, else clear selection and fit</td></tr>
404
+ <tr><td><kbd>?</kbd></td><td>toggle this dialog</td></tr>
405
+ </table>
406
+ <p class="row"><button class="btn" id="helpclose">close</button></p>
407
+ </dialog>
408
+ ${embedded}${cfg}<script>${cytoscape}</script>
409
+ <script>
410
+ // ---- inlined node-tested temporal core (src/temporal.mjs, exports stripped) ----
411
+ ${temporal}
412
+ // ---- page ----------------------------------------------------------------------
413
+ const COLORS={Module:'#3987e5',Class:'#c98500',Function:'#008300',Method:'#d55181',Attribute:'#9085e9',GlobalVariable:'#d95926',Commit:'#199e70'};
414
+ const TYPES=Object.keys(COLORS);
415
+ const DEFAULT_OFF=new Set(['Commit']); // history stays a panel fact by default, not canvas noise
416
+ const $=id=>document.getElementById(id);
417
+ const esc=s=>String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
418
+ async function loadData(){
419
+ const q=new URLSearchParams(location.search).get('data');
420
+ if(!q){const emb=document.getElementById('seonix-data');if(emb)return JSON.parse(emb.textContent);}
421
+ const cfgEl=document.getElementById('seonix-cfg');
422
+ const url=q||(cfgEl?JSON.parse(cfgEl.textContent).data:'./code-browser-data.json');
423
+ const r=await fetch(url);
424
+ if(!r.ok)throw new Error('HTTP '+r.status+' loading '+url);
425
+ return r.json();
426
+ }
427
+ const S={tg:null,byId:new Map(),cursor:0,head:0,cursorB:null,active:'a',enabled:new Set(TYPES.filter(t=>!DEFAULT_OFF.has(t))),minDeg:2,q:'',qSet:null,sel:null,playing:false,cy:null,grav:false,heat:false};
428
+ function init(tg){
429
+ S.tg=tg;S.head=Math.max(0,tg.commits.length-1);S.cursor=S.head;
430
+ for(const n of tg.nodes)S.byId.set(n.id,n);
431
+ document.title='seon chronograph — '+(tg.meta&&tg.meta.scope||'');
432
+ $('scopelabel').textContent=(tg.meta&&tg.meta.scope?'· '+tg.meta.scope:'')+' · '+tg.commits.length+' commits · '+tg.nodes.length+' nodes'+(tg.live?' · live':'');
433
+ if(tg.siteNav)$('sitenav').hidden=false;
434
+ // ---- URL state in (query-as-URL: the whole view is the link) ----
435
+ const st=decodeViewState(location.search);
436
+ if(st.q){S.q=st.q;$('q').value=st.q;S.qSet=matchQuery(tg,st.q);}
437
+ const at=resolveCursor(tg,st.at);if(at!=null)S.cursor=at;
438
+ const b=resolveCursor(tg,st.b);if(b!=null)S.cursorB=b;
439
+ if(st.types){S.enabled=new Set(st.types.split(',').filter(t=>TYPES.includes(t)));}
440
+ S.minDeg=st.deg;$('deg').value=st.deg;
441
+ if(st.sel&&S.byId.has(st.sel))S.sel=st.sel;
442
+ S.grav=st.g==='1';S.heat=st.heat==='1';
443
+ buildChips();
444
+ buildCy(tg);
445
+ wire();
446
+ $('grav').classList.toggle('on',S.grav);$('heat').classList.toggle('on',S.heat);
447
+ if(S.cursorB!=null)setCompare(true,true);
448
+ render();
449
+ if(S.sel)showBiography(S.sel);else detailEmpty();
450
+ S.cy.fit(undefined,40);
451
+ if(S.grav)runGravityLayout();
452
+ window.__chronoDebug={cy:S.cy,state:S}; // scripted checks drive this directly
453
+ if(tg.live)startLivePoll(tg.gitHead);
454
+ }
455
+ // P3 live re-annotate: only 'viz --serve' sets tg.live (never the static site
456
+ // build). A cheap poll (git rev-parse HEAD only, no graph rebuild) decides
457
+ // whether anything moved; since the whole view already round-trips through the
458
+ // URL (P1), a plain reload picks up the fresh index without losing state.
459
+ function startLivePoll(headAtLoad){
460
+ let last=headAtLoad;
461
+ setInterval(async()=>{
462
+ try{
463
+ const r=await fetch('/code-browser-version');
464
+ if(!r.ok)return;
465
+ const{head}=await r.json();
466
+ if(head&&head!==last){last=head;location.reload();}
467
+ }catch{/* offline/stopped server — stay on the current view */}
468
+ },5000);
469
+ }
470
+ function shortAt(i){const c=S.tg.commits[i];return c?c.shortSha:String(i);}
471
+ // every state commit rewrites the URL — the view IS the link (history.replaceState)
472
+ function syncUrl(){
473
+ const defTypes=TYPES.filter(t=>!DEFAULT_OFF.has(t)).join(',');
474
+ const curTypes=TYPES.filter(t=>S.enabled.has(t)).join(',');
475
+ const qs=encodeViewState({q:S.q,at:S.cursor===S.head?'':shortAt(S.cursor),b:S.cursorB==null?'':shortAt(S.cursorB),types:curTypes===defTypes?'':curTypes,deg:S.minDeg,sel:S.sel||'',g:S.grav?'1':'',heat:S.heat?'1':''});
476
+ const keep=new URLSearchParams(location.search).get('data');
477
+ const full=(keep?'data='+encodeURIComponent(keep)+(qs?'&':''):'')+qs;
478
+ history.replaceState(null,'',full?('?'+full):location.pathname);
479
+ }
480
+ function buildChips(){
481
+ $('legend').innerHTML=TYPES.map(t=>'<label class="lg" title="show/hide '+t+' nodes"><input type="checkbox" class="typechk" data-cls="'+t+'"'+(S.enabled.has(t)?' checked':'')+'><i style="background:'+COLORS[t]+'"></i>'+t+'<span class="cnt" data-cnt="'+t+'"></span></label>').join(' ');
482
+ document.querySelectorAll('.typechk').forEach(c=>c.addEventListener('change',()=>{c.checked?S.enabled.add(c.dataset.cls):S.enabled.delete(c.dataset.cls);render();}));
483
+ }
484
+ function buildCy(tg){
485
+ const els=[];
486
+ for(const n of tg.nodes)els.push({data:{id:n.id,label:n.label,type:n.type,color:COLORS[n.type]||'#8a8f98'}});
487
+ let i=0;for(const e of tg.edges)els.push({data:{id:'e'+(i++),source:e.src,target:e.dst,kind:e.kind}});
488
+ S.cy=cytoscape({container:$('cy'),elements:els,style:[
489
+ // heat rides the UNDERLAY (a warm single-hue halo, opacity = recency-weighted churn)
490
+ // so type identity (the fill) and diff status (the border) are never impersonated
491
+ {selector:'node',style:{'background-color':'data(color)',label:'data(label)',color:'#fff','font-size':9,'min-zoomed-font-size':8,'text-outline-color':'#000','text-outline-width':2,'text-wrap':'wrap','text-max-width':110,width:16,height:16,'underlay-color':'#ff9e64','underlay-opacity':0,'underlay-padding':4}},
492
+ {selector:"node[type = 'Commit']",style:{shape:'diamond','font-size':8}},
493
+ {selector:'edge',style:{width:1,'line-color':'#3b4261','target-arrow-color':'#3b4261','target-arrow-shape':'triangle','curve-style':'bezier','arrow-scale':0.7}},
494
+ {selector:"edge[kind = 'touchesSymbol']",style:{'line-color':'#565f89','line-style':'dashed'}},
495
+ {selector:"edge[kind = 'cochange']",style:{'line-color':'#9085e9','line-style':'dotted'}},
496
+ {selector:'.sel',style:{'border-width':3,'border-color':'#fff','z-index':99}},
497
+ // diff status rides BORDERS so type identity (the fill) is never impersonated
498
+ {selector:'.diff-added',style:{'border-width':3,'border-color':'#4fd67a'}},
499
+ {selector:'.diff-changed',style:{'border-width':3,'border-color':'#e0af68'}},
500
+ {selector:'.diff-removed',style:{'border-width':3,'border-color':'#f7768e','border-style':'dashed'}},
501
+ {selector:'.diff-bg',style:{opacity:0.22}},
502
+ {selector:'edge.diff-wired',style:{width:2,'line-color':'#4fd67a','target-arrow-color':'#4fd67a'}}
503
+ ],layout:{name:'cose',animate:false,nodeRepulsion:6000,idealEdgeLength:60},wheelSensitivity:0.2});
504
+ S.cy.on('tap','node',ev=>{S.sel=ev.target.id();showBiography(S.sel);syncUrl();});
505
+ S.cy.on('tap',ev=>{if(ev.target===S.cy){S.sel=null;S.cy.$('.sel').removeClass('sel');detailEmpty();syncUrl();}});
506
+ }
507
+ // core render: visibility from interval x types x declutter x query; diff classes in compare mode
508
+ function render(){
509
+ const cy=S.cy,idx=S.cursor;
510
+ const cmp=S.cursorB!=null;
511
+ const lo=cmp?Math.min(idx,S.cursorB):idx, hi=cmp?Math.max(idx,S.cursorB):idx;
512
+ const d=cmp?structuralDiff(S.tg,lo,hi):null;
513
+ const added=d?new Set(d.added):null, removed=d?new Set(d.removed):null, changed=d?new Set(d.changed):null;
514
+ const wired=d?new Set(d.wired.map(e=>e.src+'|'+e.dst+'|'+e.kind)):null;
515
+ // heat is paused in compare mode: the diff owns the attention channel there
516
+ const hs=(S.heat&&!cmp)?heatScale(S.tg,idx):null;
517
+ const grav=S.grav?gravityAt(S.tg,hi):null;
518
+ const gW=grav?new Map(grav.map(e=>[e.src+'|'+e.dst,e.w])):null;
519
+ const gMax=grav&&grav.length?Math.max(...grav.map(e=>e.w)):1;
520
+ cy.batch(()=>{
521
+ cy.nodes().forEach(cn=>{
522
+ const n=S.byId.get(cn.id());
523
+ const typeOk=S.enabled.has(n.type);
524
+ const degOk=n.type==='Commit'||n.degree>=S.minDeg;
525
+ const qOk=!S.qSet||S.qSet.size===0||S.qSet.has(n.id);
526
+ // compare mode shows the UNION of both cursors so removed things stay visible
527
+ const alive=cmp?(aliveAt([n.born,n.died],lo)||aliveAt([n.born,n.died],hi)):aliveAt([n.born,n.died],idx);
528
+ const vis=typeOk&&degOk&&alive&&qOk;
529
+ cn.style('display',vis?'element':'none');
530
+ cn.removeClass('diff-added diff-changed diff-removed diff-bg');
531
+ if(vis&&cmp&&n.type!=='Commit'){
532
+ if(added.has(n.id))cn.addClass('diff-added');
533
+ else if(removed.has(n.id))cn.addClass('diff-removed');
534
+ else if(changed.has(n.id))cn.addClass('diff-changed');
535
+ else cn.addClass('diff-bg');
536
+ }
537
+ if(vis&&n.type!=='Commit'){
538
+ const c=churnUpTo(n.touches,hi);
539
+ const size=14+Math.min(26,Math.sqrt(c)*6);
540
+ cn.style('width',size);cn.style('height',size);
541
+ cn.style('opacity',(!cmp&&n.born===idx)?1:(cn.hasClass('diff-bg')?0.22:0.92));
542
+ }
543
+ const h=hs?(hs.get(cn.id())||0):0;
544
+ cn.style('underlay-opacity',h>0.03?0.14+0.42*h:0);
545
+ if(h>0.03)cn.style('underlay-padding',2+9*h);
546
+ });
547
+ cy.edges().forEach(ce=>{
548
+ const s=S.byId.get(ce.data('source')),t=S.byId.get(ce.data('target'));
549
+ const bothVis=cy.getElementById(s.id).style('display')==='element'&&cy.getElementById(t.id).style('display')==='element';
550
+ const alive=hi>=Math.max(s.born,t.born);
551
+ ce.style('display',bothVis&&alive?'element':'none');
552
+ ce.removeClass('diff-wired');
553
+ if(cmp&&wired.has(s.id+'|'+t.id+'|'+ce.data('kind')))ce.addClass('diff-wired');
554
+ if(ce.data('kind')==='cochange'){
555
+ const w=gW?(gW.get(s.id+'|'+t.id)??gW.get(t.id+'|'+s.id)??0):0;
556
+ ce.style('width',w?1+3*(w/gMax):1);
557
+ ce.style('opacity',S.grav?(w?0.95:0.35):1);
558
+ }
559
+ });
560
+ });
561
+ // live per-type visible/loaded counts (depth-free here: interval+filters decide)
562
+ const vis={},tot={};
563
+ S.tg.nodes.forEach(n=>{tot[n.type]=(tot[n.type]||0)+1;});
564
+ cy.nodes().forEach(cn=>{if(cn.style('display')==='element'){const t=cn.data('type');vis[t]=(vis[t]||0)+1;}});
565
+ document.querySelectorAll('.cnt').forEach(sp=>{const k=sp.dataset.cnt;sp.textContent=(vis[k]||0)+'/'+(tot[k]||0);});
566
+ // narration: single cursor = the commit's story; two cursors = the range's story
567
+ $('narr').textContent=cmp?narrateRange(S.tg,lo,hi):narrateCommit(S.tg,idx);
568
+ $('qnote').style.display=(S.qSet&&S.qSet.size===0)?'block':'none';
569
+ if(S.qSet&&S.qSet.size===0)$('qnote').textContent='query "'+S.q+'" matched nothing — showing the default view.';
570
+ drawTimeline();
571
+ syncUrl();
572
+ }
573
+ // co-change gravity: re-run the layout with cochange springs weighted by shared
574
+ // touching commits up to the cursor — clusters tighten as the cursor advances.
575
+ // Layout runs on SETTLE (toggle, drag-end, key move, play stop), never per-frame.
576
+ let gravTimer=null;
577
+ function gravityKick(){if(!S.grav)return;clearTimeout(gravTimer);gravTimer=setTimeout(runGravityLayout,350);}
578
+ function runGravityLayout(){
579
+ const hi=S.cursorB!=null?Math.max(S.cursor,S.cursorB):S.cursor;
580
+ const g=gravityAt(S.tg,hi);
581
+ const wByKey=new Map(g.map(e=>[e.src+'|'+e.dst,e.w]));
582
+ const maxW=g.length?Math.max(...g.map(e=>e.w)):1;
583
+ const wOf=e=>{const a=e.data('source'),b=e.data('target');return wByKey.get(a+'|'+b)??wByKey.get(b+'|'+a)??0;};
584
+ const vis=S.cy.elements().filter(el=>el.style('display')==='element');
585
+ vis.layout({name:'cose',animate:false,nodeRepulsion:6000,
586
+ idealEdgeLength:e=>{const w=e.data('kind')==='cochange'?wOf(e):0;return w?Math.max(24,120-96*(w/maxW)):90;},
587
+ edgeElasticity:e=>{const w=e.data('kind')==='cochange'?wOf(e):0;return w?8+40*(w/maxW):4;}
588
+ }).run();
589
+ }
590
+ function setCompare(on,skipRender){
591
+ if(on){if(S.cursorB==null)S.cursorB=Math.max(0,S.cursor-1);$('cmp').classList.add('on');$('abpick').style.display='';$('difflegend').style.display='inline-flex';S.active='b';}
592
+ else{S.cursorB=null;$('cmp').classList.remove('on');$('abpick').style.display='none';$('difflegend').style.display='none';S.active='a';}
593
+ $('pickA').classList.toggle('on',S.active==='a');$('pickB').classList.toggle('on',S.active==='b');
594
+ if(!skipRender)render();
595
+ }
596
+ function wire(){
597
+ $('cmp').addEventListener('click',()=>setCompare(S.cursorB==null));
598
+ $('grav').addEventListener('click',()=>{S.grav=!S.grav;$('grav').classList.toggle('on',S.grav);render();if(S.grav)runGravityLayout();});
599
+ $('heat').addEventListener('click',()=>{S.heat=!S.heat;$('heat').classList.toggle('on',S.heat);render();});
600
+ $('pickA').addEventListener('click',()=>{S.active='a';$('pickA').classList.add('on');$('pickB').classList.remove('on');});
601
+ $('pickB').addEventListener('click',()=>{S.active='b';$('pickB').classList.add('on');$('pickA').classList.remove('on');});
602
+ $('deg').addEventListener('input',e=>{S.minDeg=Math.max(0,+e.target.value||0);render();});
603
+ let qt=null;
604
+ $('q').addEventListener('input',e=>{clearTimeout(qt);qt=setTimeout(()=>{S.q=e.target.value.trim();S.qSet=matchQuery(S.tg,S.q);render();},250);});
605
+ $('reset').addEventListener('click',()=>S.cy.fit(undefined,40));
606
+ $('zoomin').addEventListener('click',()=>S.cy.zoom({level:S.cy.zoom()*1.2,renderedPosition:{x:S.cy.width()/2,y:S.cy.height()/2}}));
607
+ $('zoomout').addEventListener('click',()=>S.cy.zoom({level:S.cy.zoom()/1.2,renderedPosition:{x:S.cy.width()/2,y:S.cy.height()/2}}));
608
+ $('play').addEventListener('click',togglePlay);
609
+ const canvas=$('tlcanvas');
610
+ const seek=x=>{const r=canvas.getBoundingClientRect();const f=Math.min(1,Math.max(0,(x-r.left)/r.width));const i=Math.round(f*S.head);
611
+ if(S.cursorB!=null&&S.active==='b')S.cursorB=i;else S.cursor=i;render();};
612
+ let drag=false;
613
+ canvas.addEventListener('mousedown',e=>{drag=true;stopPlay();seek(e.clientX);});
614
+ window.addEventListener('mousemove',e=>{if(drag)seek(e.clientX);});
615
+ window.addEventListener('mouseup',()=>{if(drag)gravityKick();drag=false;});
616
+ window.addEventListener('resize',drawTimeline);
617
+ // P3 keyboard graph-nav: focus-trapped via the native <dialog> (Esc/backdrop/×
618
+ // all close it; focus returns to whatever opened it — no bespoke trap code).
619
+ const help=$('helpdlg');
620
+ let helpOpener=null;
621
+ const openHelp=()=>{helpOpener=document.activeElement;help.showModal();};
622
+ const closeHelp=()=>{help.close();if(helpOpener&&helpOpener.focus)helpOpener.focus();helpOpener=null;};
623
+ $('help').addEventListener('click',openHelp);
624
+ $('helpclose').addEventListener('click',closeHelp);
625
+ help.addEventListener('cancel',e=>{e.preventDefault();closeHelp();}); // Esc → our close (restores focus)
626
+ window.addEventListener('keydown',e=>{
627
+ if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA')return;
628
+ if(help.open){if(e.key==='Escape')closeHelp();return;} // dialog owns the keyboard while open
629
+ const move=dir=>{stopPlay();if(S.cursorB!=null&&S.active==='b')S.cursorB=Math.min(S.head,Math.max(0,S.cursorB+dir));else S.cursor=Math.min(S.head,Math.max(0,S.cursor+dir));render();gravityKick();};
630
+ if(e.key==='ArrowRight')move(1);
631
+ else if(e.key==='ArrowLeft')move(-1);
632
+ else if(e.key===' '){e.preventDefault();togglePlay();}
633
+ else if(e.key==='Escape'){S.sel=null;S.cy.$('.sel').removeClass('sel');detailEmpty();S.cy.fit(undefined,40);syncUrl();}
634
+ else if(e.key==='?'){openHelp();}
635
+ else if(e.key==='Enter'){e.preventDefault();S.sel?$('detail').focus():selectDefault();}
636
+ else if(e.key===']')jumpNeighbor('out');
637
+ else if(e.key==='[')jumpNeighbor('in');
638
+ });
639
+ }
640
+ // go-to-definition across the whole graph (PLAN_CODE_BROWSER.md §core-experience 7):
641
+ // [ / ] cycle a selected node's structural neighbours via the pure, tested
642
+ // neighborsOf(); Enter with nothing selected picks the highest-degree VISIBLE
643
+ // symbol so keyboard-only users have a deterministic way in without a mouse.
644
+ function selectDefault(){
645
+ const vis=S.cy.nodes().filter(n=>n.style('display')==='element'&&n.data('type')!=='Commit');
646
+ if(!vis.length)return;
647
+ let best=vis[0];
648
+ vis.forEach(n=>{const nn=S.byId.get(n.id()),bb=S.byId.get(best.id());if(nn&&bb&&nn.degree>bb.degree)best=n;});
649
+ S.sel=best.id();showBiography(S.sel);S.cy.center(best);syncUrl();
650
+ }
651
+ let navKey=null;
652
+ function jumpNeighbor(dir){
653
+ if(!S.sel)return selectDefault();
654
+ const list=neighborsOf(S.tg,S.sel,dir);
655
+ if(!list.length)return;
656
+ if(!navKey||navKey.id!==S.sel||navKey.dir!==dir)navKey={id:S.sel,dir,i:-1};
657
+ navKey.i=(navKey.i+1)%list.length;
658
+ const nid=list[navKey.i];
659
+ if(!S.byId.has(nid))return;
660
+ S.sel=nid;showBiography(nid);
661
+ const cn=S.cy.getElementById(nid);
662
+ if(cn&&cn.length)S.cy.center(cn);
663
+ syncUrl();
664
+ }
665
+ let playTimer=null;
666
+ function togglePlay(){S.playing?stopPlay():startPlay();}
667
+ function startPlay(){S.playing=true;$('play').textContent='❚❚ pause';if(S.cursor>=S.head)S.cursor=0;
668
+ playTimer=setInterval(()=>{if(S.cursor>=S.head){stopPlay();return;}S.cursor+=1;render();},550);}
669
+ function stopPlay(){S.playing=false;$('play').textContent='▶ play';if(playTimer){clearInterval(playTimer);playTimer=null;}gravityKick();}
670
+ function drawTimeline(){
671
+ const canvas=$('tlcanvas'),dpr=window.devicePixelRatio||1;
672
+ const w=canvas.clientWidth,h=canvas.clientHeight;
673
+ canvas.width=w*dpr;canvas.height=h*dpr;
674
+ const ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,w,h);
675
+ const n=S.tg.commits.length;
676
+ const bornAt=new Array(n).fill(0);
677
+ for(const nd of S.tg.nodes)if(nd.type!=='Commit')bornAt[nd.born]=(bornAt[nd.born]||0)+1;
678
+ const maxBorn=Math.max(1,...bornAt);
679
+ const x=i=>n<=1?w/2:(i/(n-1))*(w-12)+6;
680
+ for(let i=0;i<n;i++){const bh=(bornAt[i]/maxBorn)*(h-16);
681
+ ctx.fillStyle=i<=S.cursor?'#3d59a1':'#2a2e42';ctx.fillRect(x(i)-2,h-6-bh,4,bh);}
682
+ ctx.strokeStyle='#2a2e42';ctx.beginPath();ctx.moveTo(0,h-6);ctx.lineTo(w,h-6);ctx.stroke();
683
+ // P3 ghost-branch merges: a small muted tick above the bar marks a merge commit —
684
+ // a NEUTRAL colour (not one of the type/diff/heat channels) so it never competes
685
+ // with the borders that already carry meaning there.
686
+ ctx.fillStyle='#565f89';
687
+ for(let i=0;i<n;i++)if(S.tg.commits[i]&&S.tg.commits[i].merge){const cx=x(i);ctx.fillRect(cx-1,2,2,6);}
688
+ const drawCursor=(i,color,tag)=>{const cx=x(i);
689
+ ctx.strokeStyle=color;ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(cx,10);ctx.lineTo(cx,h-2);ctx.stroke();
690
+ ctx.fillStyle=color;ctx.beginPath();ctx.arc(cx,10,4,0,Math.PI*2);ctx.fill();
691
+ ctx.font='9px system-ui';ctx.fillText(tag,cx+6,12);};
692
+ if(S.cursorB!=null)drawCursor(S.cursorB,'#e0af68','B');
693
+ drawCursor(S.cursor,'#7aa2f7',S.cursorB!=null?'A':'');
694
+ const c=S.tg.commits[S.cursor];
695
+ const mergeNote=c&&c.merge?' <span style="color:#565f89">· ⑂ merge ('+(c.parentIdx.length+c.ghostParents)+' parent'+((c.parentIdx.length+c.ghostParents)===1?'':'s')+')</span>':'';
696
+ $('tlmeta').innerHTML=(c?('<b>'+esc(c.shortSha)+'</b> · '+(c.date||'').slice(0,10)+' · '+esc(c.author)+' — '+esc(c.subject)):'')
697
+ +mergeNote
698
+ +(S.cursorB!=null?' <span style="color:#e0af68">· B: '+esc(shortAt(S.cursorB))+'</span>':'')
699
+ +' <span style="color:#565f89">('+(S.cursor+1)+' / '+S.tg.commits.length+')</span>';
700
+ }
701
+ function nodeUrl(n){
702
+ if(!S.tg.repoUrl)return null;
703
+ if(n.type==='Commit'&&n.id.startsWith('commit:'))return S.tg.repoUrl+'/-/commit/'+n.id.slice(7);
704
+ if(n.site){const i=n.site.lastIndexOf(':');return S.tg.repoUrl+'/-/blob/'+S.tg.repoRef+'/'+n.site.slice(0,i)+'#L'+n.site.slice(i+1);}
705
+ if(n.id.startsWith('mod:'))return S.tg.repoUrl+'/-/blob/'+S.tg.repoRef+'/'+n.id.slice(4);
706
+ return null;
707
+ }
708
+ function detailEmpty(){
709
+ $('detail').innerHTML='<p class="empty">Click a node for its biography, or press <kbd>Enter</kbd> to select one (see <kbd>?</kbd> for the full key map).<br>Drag the timeline to scrub; ▶ plays the history — a small tick above the bar marks a merge commit.<br><b>compare</b> pins cursor A and moves B — the graph shows what the range added (green), changed (amber) and removed (red).<br><b>gravity</b> pulls co-changing symbols into clusters (tightening as the cursor advances); <b>heat</b> paints recency-weighted churn as a warm halo.<br>Query grammar: <code>type:Class</code> · <code>touched:&gt;N</code> · <code>since:&lt;sha&gt;</code> · <code>cochange:&lt;name&gt;</code> · free words match name/path.<br>The URL always encodes the exact view — copy it to share.</p>'
710
+ +'<p class="hint">'+S.tg.nodes.length+' nodes · '+S.tg.edges.length+' edges · scope '+esc(S.tg.meta&&S.tg.meta.scope||'')+'</p>';
711
+ }
712
+ function showBiography(id){
713
+ const cy=S.cy;cy.$('.sel').removeClass('sel');cy.getElementById(id).addClass('sel');
714
+ const n=S.byId.get(id);if(!n)return;
715
+ const bornC=S.tg.commits[n.born];
716
+ const url=nodeUrl(n);
717
+ if(n.type==='Commit'){
718
+ const c=S.tg.commits[n.commitIdx];
719
+ const touched=S.tg.edges.filter(e=>e.src===id&&e.kind==='touchesSymbol').map(e=>S.byId.get(e.dst)).filter(Boolean);
720
+ // P3 ghost-branch merges: parents present on the timeline are jump-to; parents
721
+ // whose own commits never touched a tracked symbol surface as a ghost count —
722
+ // the branch existed, we just can't scrub INTO it (HEAD-only index, same
723
+ // honesty rule as the rest of the panel).
724
+ const parentsHtml=c.parentIdx.length||c.ghostParents
725
+ ?'<h4>'+(c.merge?'⑂ merge — ':'')+(c.parentIdx.length+c.ghostParents)+' parent(s)</h4><ul>'
726
+ +c.parentIdx.map(pi=>'<li><a href="#" class="jump-parent" data-idx="'+pi+'"><code>'+esc(S.tg.commits[pi].shortSha)+'</code></a></li>').join('')
727
+ +(c.ghostParents?'<li class="hint">+'+c.ghostParents+' ghost parent(s) — untracked commit(s), branch not otherwise visible</li>':'')
728
+ +'</ul>'
729
+ :'';
730
+ $('detail').innerHTML='<h3>'+esc(c.shortSha)+'</h3>'
731
+ +'<span class="badge"><i style="background:'+COLORS.Commit+'"></i>Commit '+(c.idx+1)+' of '+S.tg.commits.length+'</span>'
732
+ +'<div class="row">'+esc(c.author)+' · '+(c.date||'').slice(0,10)+'</div><div class="row"><em>'+esc(c.subject)+'</em></div>'
733
+ +'<div class="row">'+(url?'<a class="btn" href="'+url+'" target="_blank" rel="noopener">open in GitLab ↗</a>':'')
734
+ +'<button class="btn" id="gocommit">scrub here</button></div>'
735
+ +parentsHtml
736
+ +'<h4>touched '+touched.length+' symbol(s)</h4><ul>'+touched.slice(0,25).map(t=>'<li>'+esc(t.label)+'</li>').join('')+'</ul>';
737
+ $('gocommit').addEventListener('click',()=>{S.cursor=c.idx;render();});
738
+ $('detail').querySelectorAll('.jump-parent').forEach(a=>a.addEventListener('click',e=>{
739
+ e.preventDefault();S.cursor=+a.dataset.idx;S.sel='commit:'+S.tg.commits[+a.dataset.idx].sha;render();showBiography(S.sel);
740
+ }));
741
+ return;
742
+ }
743
+ const outE=S.tg.edges.filter(e=>e.src===id&&e.kind!=='touchesSymbol');
744
+ const inE=S.tg.edges.filter(e=>e.dst===id&&e.kind!=='touchesSymbol');
745
+ const touchers=S.tg.edges.filter(e=>e.dst===id&&e.kind==='touchesSymbol').map(e=>S.byId.get(e.src)).filter(Boolean);
746
+ const lbl=nid=>esc(S.byId.get(nid)?.label||nid);
747
+ $('detail').innerHTML='<h3>'+esc(n.label)+'</h3>'
748
+ +'<span class="badge"><i style="background:'+(COLORS[n.type]||'#8a8f98')+'"></i>'+esc(n.type)+'</span>'
749
+ +(n.site?'<div class="row"><code>'+esc(n.site)+'</code></div>':'')
750
+ +'<div class="row">'+(url?'<a class="btn" href="'+url+'" target="_blank" rel="noopener">open in GitLab ↗</a>':'')
751
+ +'<button class="btn" id="gobirth">jump to birth</button></div>'
752
+ +'<h4>life</h4><div>born at <b>'+(bornC?esc(bornC.shortSha):'?')+'</b>'+(bornC?' — '+esc(bornC.subject):'')+'</div>'
753
+ +'<div>churn '+n.churn+' touching commit(s) · degree '+n.degree+'</div>'
754
+ +'<h4>touched by '+touchers.length+' commit(s)</h4><ul>'+ (touchers.slice(0,10).map(c=>'<li><code>'+esc(c.label)+'</code></li>').join('')||'<li class="hint">no direct history (born = first-seen)</li>')+'</ul>'
755
+ +'<h4>edges out ('+outE.length+')</h4><ul>'+(outE.slice(0,15).map(e=>'<li>'+esc(e.kind)+' → '+lbl(e.dst)+'</li>').join('')||'<li class="hint">none</li>')+'</ul>'
756
+ +'<h4>edges in ('+inE.length+')</h4><ul>'+(inE.slice(0,15).map(e=>'<li>'+lbl(e.src)+' → '+esc(e.kind)+'</li>').join('')||'<li class="hint">none</li>')+'</ul>';
757
+ $('gobirth').addEventListener('click',()=>{S.cursor=n.born;render();});
758
+ }
759
+ loadData().then(init).catch(err=>{
760
+ $('detail').innerHTML='<p class="empty">Failed to load the temporal graph: '+esc(err.message)
761
+ +'</p><p class="hint">Serve a code-browser-data.json next to this page, pass ?data=&lt;url&gt;, or regenerate with seonix viz --browser-out.</p>';
762
+ });
763
+ </script></body></html>`;
764
+ }
765
+
766
+ /** Convenience: load the temporal source and render (the async front door). */
767
+ export async function renderBrowserHtml({ cytoscape, embedData = null, dataPath = null } = {}) {
768
+ return renderBrowserPage({ cytoscape, temporal: await temporalSource(), embedData, dataPath });
769
+ }