@rune-kit/rune 2.18.1 → 2.20.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.
Files changed (34) hide show
  1. package/README.md +22 -7
  2. package/compiler/__tests__/comprehension.test.js +916 -0
  3. package/compiler/__tests__/governance-collector.test.js +376 -0
  4. package/compiler/__tests__/setup.test.js +5 -0
  5. package/compiler/analytics.js +5 -0
  6. package/compiler/bin/rune.js +165 -1
  7. package/compiler/comprehension-client.js +2348 -0
  8. package/compiler/comprehension.js +1254 -0
  9. package/compiler/governance-collector.js +382 -0
  10. package/compiler/schemas/comprehension.schema.json +87 -0
  11. package/compiler/schemas/governance.schema.json +78 -0
  12. package/compiler/transforms/branding.js +1 -1
  13. package/hooks/context-watch/index.cjs +24 -13
  14. package/hooks/hooks.json +2 -2
  15. package/hooks/lib/context-key.cjs +37 -0
  16. package/hooks/metrics-collector/index.cjs +37 -8
  17. package/hooks/pre-tool-guard/index.cjs +44 -0
  18. package/hooks/session-start/index.cjs +4 -10
  19. package/package.json +1 -1
  20. package/skills/adversary/SKILL.md +36 -3
  21. package/skills/adversary/references/cross-model-escalation.md +85 -0
  22. package/skills/adversary/references/reasoning-modes.md +95 -0
  23. package/skills/autopsy/SKILL.md +41 -0
  24. package/skills/ba/SKILL.md +44 -2
  25. package/skills/ba/references/ears-format.md +91 -0
  26. package/skills/brainstorm/SKILL.md +32 -7
  27. package/skills/cook/SKILL.md +8 -1
  28. package/skills/debug/SKILL.md +4 -2
  29. package/skills/deploy/SKILL.md +20 -1
  30. package/skills/deploy/references/observability.md +146 -0
  31. package/skills/graft/SKILL.md +24 -8
  32. package/skills/onboard/SKILL.md +45 -0
  33. package/skills/perf/SKILL.md +4 -1
  34. package/skills/review/SKILL.md +4 -2
@@ -0,0 +1,2348 @@
1
+ /**
2
+ * Comprehension Dashboard — Client Application Script
3
+ *
4
+ * The browser-side application that generateComprehensionHTML() in
5
+ * comprehension.js embeds into the dashboard's <script> tag. This is a STATIC
6
+ * template literal — it contains no server-side ${} interpolation. The only
7
+ * dynamic value, `const D`, is injected by the generator immediately before
8
+ * this script. Extracted from comprehension.js (was a single ~3.6k-line file)
9
+ * to keep the server-side generator navigable.
10
+ *
11
+ * Contains: tab switching, persona / My-Lens (Pro) profile, the Govern,
12
+ * Measure and Improve renderers, and the Understand-tab canvas graph with
13
+ * keyboard accessibility.
14
+ */
15
+
16
+ export const CLIENT_SCRIPT = `
17
+ // ── Helpers ──
18
+ function esc(s){
19
+ return String(s==null?'':s)
20
+ .replace(/&/g,'&amp;')
21
+ .replace(/</g,'&lt;')
22
+ .replace(/>/g,'&gt;')
23
+ .replace(/"/g,'&quot;')
24
+ .replace(/'/g,'&#39;');
25
+ }
26
+
27
+ function safeInt(v){const n=parseInt(v,10);return isNaN(n)?0:n;}
28
+ function safePct(n,d){return d>0?Math.round(n/d*100):null;}
29
+
30
+ // ── Tab switching ──
31
+ const tabs = document.querySelectorAll('.tab');
32
+ const panels = document.querySelectorAll('.tab-panel');
33
+
34
+ tabs.forEach((tab,i)=>{
35
+ tab.addEventListener('click',()=>{
36
+ tabs.forEach(t=>{t.classList.remove('active');t.setAttribute('aria-selected','false');});
37
+ panels.forEach(p=>p.classList.remove('active'));
38
+ tab.classList.add('active');
39
+ tab.setAttribute('aria-selected','true');
40
+ panels[i].classList.add('active');
41
+ if(tab.id==='tab-understand') renderUnderstandGraph();
42
+ });
43
+ });
44
+
45
+ // ── Profile state ──
46
+ // Loaded from D.initialPersona (seeded from .rune/dashboard-profile.json on generate).
47
+ // Also persisted to localStorage for instant UX within the same browser session.
48
+ const LS_KEY='rune_dashboard_profile';
49
+ // 'mylens' is only valid when Pro is available (D.hasPro is embedded server-side).
50
+ const VALID_PERSONAS=D.hasPro?['exec','compliance','eng','mylens']:['exec','compliance','eng'];
51
+ function clampPersona(v){
52
+ return VALID_PERSONAS.includes(v)?v:(VALID_PERSONAS.includes(D.initialPersona)?D.initialPersona:'exec');
53
+ }
54
+ function loadProfile(){
55
+ // Prefer localStorage (instant UX within session) over D.initialPersona.
56
+ // Normalize shape on load: guard against missing/unknown fields from tampered storage.
57
+ try{
58
+ const raw=localStorage.getItem(LS_KEY);
59
+ if(raw){
60
+ const p=JSON.parse(raw);
61
+ return {
62
+ persona:clampPersona(p?.persona),
63
+ pinnedConcerns:Array.isArray(p?.pinnedConcerns)?p.pinnedConcerns:[],
64
+ };
65
+ }
66
+ }catch{}
67
+ return {persona:clampPersona(D.initialPersona),pinnedConcerns:Array.isArray(D.initialPinned)?D.initialPinned:[]};
68
+ }
69
+ function saveProfile(p){
70
+ try{localStorage.setItem(LS_KEY,JSON.stringify(p));}catch{}
71
+ }
72
+ let currentProfile=loadProfile();
73
+
74
+ // ── Persona toggle ──
75
+ // Personas re-emphasize sections:
76
+ // Exec → scorecard + verdict prominent; raw tables de-emphasised
77
+ // Compliance → compliance coverage + ledger first; details shown
78
+ // Eng → full detail including provenance + raw events
79
+ // My Lens → Pro-only; personal focus: cost, gates, skill-ROI
80
+ const PERSONA_LABELS={exec:'Exec',compliance:'Compliance',eng:'Eng',mylens:'My Lens'};
81
+ const PERSONA_DESCRIPTIONS={
82
+ exec:'Scorecard and verdict at a glance.',
83
+ compliance:'Compliance coverage and obligation ledger.',
84
+ eng:'Full detail including raw events and provenance.',
85
+ mylens:'Your personal leverage: cost, gates fired, and skill ROI.',
86
+ };
87
+
88
+ function applyPersona(persona){
89
+ currentProfile={...currentProfile,persona};
90
+ saveProfile(currentProfile);
91
+
92
+ // Update button states
93
+ document.querySelectorAll('.persona button').forEach(b=>{
94
+ const active=b.dataset.persona===persona;
95
+ b.classList.toggle('on',active);
96
+ b.setAttribute('aria-pressed',active?'true':'false');
97
+ });
98
+
99
+ // My Lens: activate Govern tab panel directly (without re-triggering renderGovern),
100
+ // then replace govern-content with My Lens curated view.
101
+ if(persona==='mylens'){
102
+ // Activate Govern tab visually without dispatching a click (which would call renderGovern)
103
+ document.querySelectorAll('.tab').forEach(t=>{
104
+ t.classList.remove('active');t.setAttribute('aria-selected','false');
105
+ });
106
+ document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
107
+ const governTab=document.getElementById('tab-govern');
108
+ const governPanel=document.getElementById('panel-govern');
109
+ if(governTab){governTab.classList.add('active');governTab.setAttribute('aria-selected','true');}
110
+ if(governPanel){governPanel.classList.add('active');}
111
+ // Replace govern-content with My Lens view
112
+ const container=document.getElementById('govern-content');
113
+ if(container){container.innerHTML='';renderMyLens();}
114
+ return;
115
+ }
116
+
117
+ // Switching away from mylens → restore govern-content to upsell or full govern
118
+ const container=document.getElementById('govern-content');
119
+ if(container && container.querySelector('.mylens-header')){
120
+ // My Lens was showing — replace with standard govern view
121
+ container.innerHTML='';
122
+ renderGovern();
123
+ }
124
+
125
+ // Section visibility/order — use data-persona-hide attribute
126
+ // Exec: hide raw tables (.govern-eng-only), show scorecard first
127
+ // Compliance: show coverage first (.govern-compliance-first reorder)
128
+ // Eng: show everything
129
+ document.querySelectorAll('[data-exec-hide]').forEach(el=>{
130
+ el.style.display=persona==='exec'?'none':'';
131
+ });
132
+ document.querySelectorAll('[data-eng-only]').forEach(el=>{
133
+ el.style.display=persona==='eng'?'':'none';
134
+ });
135
+ document.querySelectorAll('[data-compliance-priority]').forEach(el=>{
136
+ el.style.order=persona==='compliance'?'-1':'0';
137
+ });
138
+
139
+ // Update profile status label
140
+ const statusEl=document.getElementById('profile-status-label');
141
+ if(statusEl)statusEl.textContent=PERSONA_DESCRIPTIONS[persona]||'';
142
+ }
143
+
144
+ document.querySelectorAll('.persona button').forEach(btn=>{
145
+ btn.addEventListener('click',()=>{
146
+ applyPersona(btn.dataset.persona||'exec');
147
+ });
148
+ });
149
+
150
+ // ── Count-up animation ──
151
+ function countUp(el,target,suffix,decimals,duration){
152
+ if(!Number.isFinite(target))return; // guard: never write NaN or Infinity
153
+ if(matchMedia('(prefers-reduced-motion:reduce)').matches){
154
+ el.textContent=target.toFixed(decimals)+suffix;return;
155
+ }
156
+ const steps=50,interval=Math.min(duration/steps,20);
157
+ let cur=0;
158
+ const inc=target/steps;
159
+ const t=setInterval(()=>{
160
+ cur+=inc;
161
+ if(cur>=target){cur=target;clearInterval(t);}
162
+ el.textContent=cur.toFixed(decimals)+suffix;
163
+ },interval);
164
+ }
165
+
166
+ // ── Verdict score count-up ──
167
+ window.addEventListener('load',()=>{
168
+ const scoreNum=document.getElementById('score-num');
169
+ if(scoreNum && D.verdictScore!=null){
170
+ countUp(scoreNum,D.verdictScore,'',0,800);
171
+ }
172
+ const gatesEl=document.getElementById('kpi-gates');
173
+ const gatesTarget=D.gates ? D.gates.reduce((s,g)=>s+(g.fired||0),0) : 0;
174
+ if(gatesEl && gatesTarget>0) countUp(gatesEl,gatesTarget,'',0,600);
175
+
176
+ const compEl=document.getElementById('compliance-num');
177
+ if(compEl && D.compliance && D.compliance.length>0){
178
+ const met=D.compliance.filter(c=>c.status==='met').length;
179
+ const pct=safePct(met,D.compliance.length);
180
+ if(pct!==null) countUp(compEl,pct,'',0,600);
181
+ }
182
+
183
+ const skillsNum=document.getElementById('skills-num');
184
+ if(skillsNum && D.skillFrequency && D.skillFrequency.length>0){
185
+ countUp(skillsNum,D.skillFrequency.length,'',0,600);
186
+ }
187
+ });
188
+
189
+ // ── Govern tab (Phase 3 / Phase 5b) ──
190
+ function renderGovern(){
191
+ const container=document.getElementById('govern-content');
192
+ if(!container)return;
193
+
194
+ // Phase 5b — tier gate: Govern is a Business feature.
195
+ // Free and Pro tiers see an honest upsell instead of fabricated data.
196
+ if(!D.hasBusiness){
197
+ const upsell=document.createElement('div');
198
+ upsell.className='tier-upsell';
199
+ upsell.setAttribute('aria-label','Govern tab — Business tier feature');
200
+
201
+ const icon=document.createElement('div');
202
+ icon.className='upsell-icon';
203
+ icon.setAttribute('aria-hidden','true');
204
+ icon.innerHTML='&#x1F5C2;'; // 🗂
205
+
206
+ const title=document.createElement('h3');
207
+ title.textContent='Govern — compliance, gate ledger, and decision provenance';
208
+
209
+ const desc=document.createElement('p');
210
+ desc.className='upsell-desc';
211
+ desc.textContent='The Govern tab tracks your compliance obligations, shows which quality gates fired and blocked, and maps output decisions back through their signal chain. This is a Rune Business feature.';
212
+
213
+ const features=document.createElement('ul');
214
+ features.className='upsell-features';
215
+ features.setAttribute('aria-label','Govern features included in Business tier');
216
+ const featureList=[
217
+ {icon:'&#x2705;','text':'Compliance coverage map — obligations met, gap, unknown per pack'},
218
+ {icon:'&#x1F6AB;','text':'Gate ledger — sentinel, preflight, completion-gate fire + block counts'},
219
+ {icon:'&#x1F50D;','text':'Decision provenance — output ↦ signal chain ↦ gate ↦ model ↦ cost'},
220
+ {icon:'&#x1F4C8;','text':'Governance scorecard — compliance %, blocks caught, cost per feature'},
221
+ ];
222
+ for(const f of featureList){
223
+ const li=document.createElement('li');
224
+ li.className='upsell-feature';
225
+ const fi=document.createElement('span');
226
+ fi.className='upsell-feature-icon';
227
+ fi.setAttribute('aria-hidden','true');
228
+ fi.innerHTML=f.icon;
229
+ const ft=document.createElement('span');
230
+ ft.textContent=f.text;
231
+ li.appendChild(fi);
232
+ li.appendChild(ft);
233
+ features.appendChild(li);
234
+ }
235
+
236
+ const how=document.createElement('p');
237
+ how.className='upsell-how';
238
+ // Note: no external URLs here — dashboard is 100% local, no CDN or tracking.
239
+ how.innerHTML=
240
+ 'To unlock Govern, set <code>RUNE_BUSINESS_ROOT</code> to your Rune Business directory and regenerate: <code>rune dashboard</code>. '
241
+ +'Business tier ($149 lifetime) includes finance, legal, HR, and enterprise-search packs. '
242
+ +'Available at <code>github.com/sponsors/rune-kit</code>.';
243
+
244
+ upsell.appendChild(icon);
245
+ upsell.appendChild(title);
246
+ upsell.appendChild(desc);
247
+ upsell.appendChild(features);
248
+ upsell.appendChild(how);
249
+ container.appendChild(upsell);
250
+ return; // ← early return: no fabricated govern panels
251
+ }
252
+
253
+ const gates=D.gates||[];
254
+ const compliance=D.compliance||[];
255
+ const decisions=D.decisions||[];
256
+ const totalBlocks=gates.reduce((s,g)=>s+(g.blocked||0),0);
257
+ const totalFired=gates.reduce((s,g)=>s+(g.fired||0),0);
258
+ const met=compliance.filter(c=>c.status==='met').length;
259
+ const compliancePct=compliance.length>0?Math.round(met/compliance.length*100):null;
260
+
261
+ // ── Profile bar ──
262
+ const profileBar=document.createElement('div');
263
+ profileBar.className='profile-bar';
264
+ profileBar.setAttribute('aria-label','Profile actions');
265
+ const profileStatus=document.createElement('span');
266
+ profileStatus.id='profile-status-label';
267
+ profileStatus.className='profile-status';
268
+ profileStatus.textContent='';
269
+ const exportBtn=document.createElement('button');
270
+ exportBtn.textContent='Export profile';
271
+ exportBtn.setAttribute('aria-label','Download dashboard profile JSON');
272
+ exportBtn.addEventListener('click',()=>{
273
+ const blob=new Blob([JSON.stringify(currentProfile,null,2)],{type:'application/json'});
274
+ const a=document.createElement('a');
275
+ a.href=URL.createObjectURL(blob);
276
+ a.download='dashboard-profile.json';
277
+ a.click();
278
+ URL.revokeObjectURL(a.href);
279
+ });
280
+ profileBar.appendChild(profileStatus);
281
+ profileBar.appendChild(exportBtn);
282
+ container.appendChild(profileBar);
283
+
284
+ // ── Govern sections wrapper (flex col for persona ordering) ──
285
+ const sections=document.createElement('div');
286
+ sections.className='govern-sections';
287
+ container.appendChild(sections);
288
+
289
+ // ── 1. Scorecard row ── (visible to all personas, prominent for Exec)
290
+ const scorecard=document.createElement('div');
291
+ scorecard.className='govern-scorecard';
292
+ scorecard.setAttribute('aria-label','Governance scorecard');
293
+
294
+ function kpiCell(label,val,sub,subClass){
295
+ const d=document.createElement('div');
296
+ d.className='kpi';
297
+ d.innerHTML=
298
+ '<div class="k-lbl">'+esc(label)+'</div>'+
299
+ '<div class="k-val" style="font-size:22px">'+esc(String(val==null?'—':val))+'</div>'+
300
+ (sub?'<div class="k-sub'+(subClass?' '+subClass:'')+'">'+esc(sub)+'</div>':'');
301
+ return d;
302
+ }
303
+
304
+ scorecard.appendChild(kpiCell(
305
+ 'Compliance',
306
+ compliancePct!==null?compliancePct+'%':'—',
307
+ compliance.length>0?met+'/'+compliance.length+' obligations met':'no obligations captured',
308
+ met>0?'up':'',
309
+ ));
310
+ scorecard.appendChild(kpiCell(
311
+ 'Gates fired',
312
+ totalFired>0?totalFired:'—',
313
+ totalFired>0?gates.length+' gate'+(gates.length!==1?'s':'')+' active':'no gates yet',
314
+ totalFired>0?'up':'',
315
+ ));
316
+ scorecard.appendChild(kpiCell(
317
+ 'Blocks caught',
318
+ totalBlocks>0?totalBlocks:'—',
319
+ totalBlocks>0?'real block events captured':'not captured yet',
320
+ totalBlocks>0?'warn':'',
321
+ ));
322
+ scorecard.appendChild(kpiCell(
323
+ 'Cost / feature',
324
+ '—',
325
+ 'not captured yet',
326
+ '',
327
+ ));
328
+ sections.appendChild(scorecard);
329
+
330
+ // ── 2. Two-column: Gate ledger (left) + Compliance coverage map (right) ──
331
+ const twoCol=document.createElement('div');
332
+ twoCol.className='govern-3col';
333
+ twoCol.setAttribute('data-compliance-priority','1'); // Compliance persona pulls this first
334
+ sections.appendChild(twoCol);
335
+
336
+ // ── 2a. Gate ledger ──
337
+ const ledgerWrap=document.createElement('div');
338
+ ledgerWrap.setAttribute('data-exec-hide','1'); // Exec hides raw ledger to show scorecard only
339
+ twoCol.appendChild(ledgerWrap);
340
+
341
+ const ledgerPanel=document.createElement('div');
342
+ ledgerPanel.className='panel';
343
+ ledgerWrap.appendChild(ledgerPanel);
344
+
345
+ const ledgerTitle=document.createElement('h3');
346
+ ledgerTitle.innerHTML='Governance Ledger <span class="pill">gate audit</span>';
347
+ ledgerPanel.appendChild(ledgerTitle);
348
+
349
+ if(gates.length===0){
350
+ const empty=document.createElement('div');
351
+ empty.className='empty';
352
+ empty.style.padding='30px 10px';
353
+ empty.innerHTML='<h4>No gates recorded yet</h4><p>Run Rune skills (sentinel, preflight, completion-gate, etc.) to start capturing gate fire events.</p>';
354
+ ledgerPanel.appendChild(empty);
355
+ } else {
356
+ const tbl=document.createElement('table');
357
+ tbl.setAttribute('aria-label','Governance gate ledger');
358
+ tbl.innerHTML=
359
+ '<thead><tr>'+
360
+ '<th>Gate</th>'+
361
+ '<th>Fired</th>'+
362
+ '<th>Blocked</th>'+
363
+ '<th>Passed / Bypassed</th>'+
364
+ '<th>Last event</th>'+
365
+ '</tr></thead>';
366
+ const tbody=document.createElement('tbody');
367
+ for(const g of gates){
368
+ const tr=document.createElement('tr');
369
+ const tsLabel=g.ts?new Date(g.ts).toLocaleDateString():'—';
370
+ const blocked=safeInt(g.blocked);
371
+ // passed/bypassed remain uncaptured (GAP-1 Phase 3 — honest label)
372
+ const passedLabel='not captured';
373
+ const blockedCell=blocked>0
374
+ ?'<span class="blocks-badge" aria-label="'+blocked+' block'+(blocked!==1?'s':'')+' caught">&#x26A0; '+blocked+'</span>'
375
+ :'<span style="color:var(--text-tertiary);font-size:11px">—</span>';
376
+ tr.innerHTML=
377
+ '<td class="mono">'+esc(g.name)+'</td>'+
378
+ '<td class="mono">'+safeInt(g.fired)+'</td>'+
379
+ '<td>'+blockedCell+'</td>'+
380
+ '<td style="color:var(--text-tertiary);font-size:11px">'+esc(passedLabel)+'</td>'+
381
+ '<td class="mono" style="font-size:11px">'+esc(tsLabel)+'</td>';
382
+ tbody.appendChild(tr);
383
+ }
384
+ tbl.appendChild(tbody);
385
+ ledgerPanel.appendChild(tbl);
386
+
387
+ // Blocked events detail (Eng persona only) — read from gate-outcomes embedded in gate data
388
+ const blockedGates=gates.filter(g=>(g.blocked||0)>0);
389
+ if(blockedGates.length>0){
390
+ const detail=document.createElement('details');
391
+ detail.className='blocked-events';
392
+ detail.setAttribute('data-eng-only','1');
393
+ const sum=document.createElement('summary');
394
+ sum.textContent='Raw block events ('+totalBlocks+' total) — Eng view';
395
+ detail.appendChild(sum);
396
+ const mini=document.createElement('table');
397
+ mini.setAttribute('aria-label','Raw block events');
398
+ mini.innerHTML='<thead><tr><th>Gate</th><th>Blocks captured</th><th>Most recent</th></tr></thead>';
399
+ const mb=document.createElement('tbody');
400
+ for(const g of blockedGates){
401
+ const mr=document.createElement('tr');
402
+ mr.innerHTML=
403
+ '<td class="mono" style="font-size:11px">'+esc(g.name)+'</td>'+
404
+ '<td class="mono" style="font-size:11px">'+safeInt(g.blocked)+'</td>'+
405
+ '<td style="font-size:11px;color:var(--text-tertiary)">'+esc(g.ts?new Date(g.ts).toLocaleString():'—')+'</td>';
406
+ mb.appendChild(mr);
407
+ }
408
+ mini.appendChild(mb);
409
+ detail.appendChild(mini);
410
+ ledgerPanel.appendChild(detail);
411
+ }
412
+ }
413
+
414
+ // ── 2b. Compliance coverage map ──
415
+ const compWrap=document.createElement('div');
416
+ twoCol.appendChild(compWrap);
417
+
418
+ const compPanel=document.createElement('div');
419
+ compPanel.className='panel';
420
+ compWrap.appendChild(compPanel);
421
+
422
+ const compTitle=document.createElement('h3');
423
+ compTitle.innerHTML='Compliance Coverage <span class="pill">obligations</span>';
424
+ compPanel.appendChild(compTitle);
425
+
426
+ if(compliance.length===0){
427
+ const empty=document.createElement('div');
428
+ empty.className='empty';
429
+ empty.style.padding='30px 10px';
430
+ empty.innerHTML='<h4>No obligations captured</h4><p>Business pack PACK.md Constraints + Done-When sections declare obligations. Install Rune Business to see them here.</p>';
431
+ compPanel.appendChild(empty);
432
+ } else {
433
+ // Group by pack
434
+ const byPack=new Map();
435
+ for(const c of compliance){
436
+ if(!byPack.has(c.pack))byPack.set(c.pack,[]);
437
+ byPack.get(c.pack).push(c);
438
+ }
439
+
440
+ // Pin controls
441
+ const pinBar=document.createElement('div');
442
+ pinBar.style.cssText='margin-bottom:10px;display:flex;gap:6px;flex-wrap:wrap;align-items:center;';
443
+ const pinLabel=document.createElement('span');
444
+ pinLabel.style.cssText='font-size:11px;color:var(--text-tertiary);flex-shrink:0;';
445
+ pinLabel.textContent='Pin pack:';
446
+ pinBar.appendChild(pinLabel);
447
+ for(const pk of byPack.keys()){
448
+ const chip=document.createElement('button');
449
+ chip.style.cssText='font:500 10px var(--font-mono);padding:2px 8px;border-radius:var(--r-pill);cursor:pointer;transition:all var(--t-fast);';
450
+ chip.textContent=pk.split('/').pop()||pk;
451
+ chip.title='Pin/unpin: '+pk;
452
+ chip.setAttribute('aria-label','Pin pack '+pk);
453
+ chip.setAttribute('aria-pressed',currentProfile.pinnedConcerns.includes(pk)?'true':'false');
454
+ chip.style.border=currentProfile.pinnedConcerns.includes(pk)?'1px solid var(--accent)':'1px solid var(--border-strong)';
455
+ chip.style.background=currentProfile.pinnedConcerns.includes(pk)?'rgba(45,212,191,.10)':'transparent';
456
+ chip.style.color=currentProfile.pinnedConcerns.includes(pk)?'var(--accent)':'var(--text-secondary)';
457
+ chip.addEventListener('click',()=>{
458
+ const pinned=currentProfile.pinnedConcerns;
459
+ const idx=pinned.indexOf(pk);
460
+ const newPinned=idx>=0?pinned.filter(p=>p!==pk):[...pinned,pk];
461
+ currentProfile={...currentProfile,pinnedConcerns:newPinned};
462
+ saveProfile(currentProfile);
463
+ // Re-render govern to reflect new pin state
464
+ container.innerHTML='';
465
+ renderGovern();
466
+ });
467
+ pinBar.appendChild(chip);
468
+ }
469
+ compPanel.appendChild(pinBar);
470
+
471
+ // Coverage map: pinned packs first
472
+ const sortedPacks=[...byPack.keys()].sort((a,b)=>{
473
+ const aPin=currentProfile.pinnedConcerns.includes(a)?0:1;
474
+ const bPin=currentProfile.pinnedConcerns.includes(b)?0:1;
475
+ return aPin-bPin||a.localeCompare(b);
476
+ });
477
+
478
+ const covList=document.createElement('div');
479
+ covList.setAttribute('aria-label','Compliance coverage by pack');
480
+ for(const pk of sortedPacks){
481
+ const items=byPack.get(pk);
482
+ const pkMet=items.filter(c=>c.status==='met').length;
483
+ const pkUnknown=items.filter(c=>c.status==='unknown').length;
484
+ const pkGap=items.filter(c=>c.status==='gap'||c.status==='partial').length;
485
+ const isPinned=currentProfile.pinnedConcerns.includes(pk);
486
+
487
+ const packDiv=document.createElement('div');
488
+ packDiv.className='cov-pack'+(isPinned?' pinned':'');
489
+
490
+ // Pack header with bar
491
+ const hdr=document.createElement('div');
492
+ hdr.className='cov-pack-header';
493
+ const nameSpan=document.createElement('span');
494
+ nameSpan.className='cov-pack-name';
495
+ nameSpan.title=pk;
496
+ nameSpan.textContent=pk;
497
+ const pctSpan=document.createElement('span');
498
+ const pctVal=items.length>0?Math.round(pkMet/items.length*100):0;
499
+ // Status pill for the pack: color+icon+text (never color-only per design system)
500
+ let pkStClass='info',pkStIcon='?',pkStText='unknown';
501
+ if(pkMet===items.length&&items.length>0){pkStClass='pass';pkStIcon='&#10003;';pkStText='all met';}
502
+ else if(pkGap>0){pkStClass='fail';pkStIcon='&#10005;';pkStText=pkGap+' gap'+(pkGap!==1?'s':'');}
503
+ else if(pkMet>0){pkStClass='warn';pkStIcon='&#9651;';pkStText=pkMet+'/'+items.length+' met';}
504
+ pctSpan.innerHTML='<span class="st '+pkStClass+'" aria-label="Pack status: '+pkStText+'">'+pkStIcon+' '+esc(pkStText)+'</span>';
505
+ hdr.appendChild(nameSpan);
506
+ hdr.appendChild(pctSpan);
507
+ packDiv.appendChild(hdr);
508
+
509
+ // Coverage bar
510
+ const barTrack=document.createElement('div');
511
+ barTrack.className='cov-bar-track';
512
+ barTrack.setAttribute('role','progressbar');
513
+ barTrack.setAttribute('aria-valuenow',String(pctVal));
514
+ barTrack.setAttribute('aria-valuemin','0');
515
+ barTrack.setAttribute('aria-valuemax','100');
516
+ barTrack.setAttribute('aria-label',pctVal+'% obligations met for '+pk);
517
+ const barFill=document.createElement('div');
518
+ barFill.className='cov-bar-fill';
519
+ barFill.style.width=pctVal+'%';
520
+ barFill.style.background=pkMet===items.length&&items.length>0?'var(--pass)':pkGap>0?'var(--fail)':'var(--warn)';
521
+ barTrack.appendChild(barFill);
522
+ packDiv.appendChild(barTrack);
523
+
524
+ // Items (Compliance+Eng see all; Exec sees pack-level summary only)
525
+ const itemsDiv=document.createElement('div');
526
+ itemsDiv.className='cov-items';
527
+ itemsDiv.setAttribute('data-exec-hide','1');
528
+ for(const c of items){
529
+ const row=document.createElement('div');
530
+ row.className='cov-item';
531
+ let stClass='info',stIcon='?',stText=c.status;
532
+ if(c.status==='met'){stClass='pass';stIcon='&#10003;';stText='met';}
533
+ else if(c.status==='gap'){stClass='fail';stIcon='&#10005;';stText='gap';}
534
+ else if(c.status==='partial'){stClass='warn';stIcon='&#9651;';stText='partial';}
535
+ else{stClass='info';stIcon='&#8253;';stText='not verified';}
536
+ row.innerHTML=
537
+ '<span class="st '+stClass+'" aria-label="Status: '+esc(stText)+'" style="flex-shrink:0;margin-top:1px">'+stIcon+'</span>'+
538
+ '<span class="cov-item-text">'+esc(c.obligation)+'</span>';
539
+ itemsDiv.appendChild(row);
540
+ }
541
+ packDiv.appendChild(itemsDiv);
542
+ covList.appendChild(packDiv);
543
+ }
544
+ compPanel.appendChild(covList);
545
+ }
546
+
547
+ // ── 3. Decision Provenance ──
548
+ const provPanel=document.createElement('div');
549
+ provPanel.className='panel govern-provenance';
550
+ provPanel.setAttribute('data-eng-only','1'); // Exec/Compliance: hide raw provenance
551
+ const provTitle=document.createElement('h3');
552
+ provTitle.innerHTML='Decision Provenance <span class="pill">trace</span>';
553
+ provPanel.appendChild(provTitle);
554
+
555
+ if(decisions.length===0){
556
+ const empty=document.createElement('div');
557
+ empty.className='empty';
558
+ empty.style.padding='30px 10px';
559
+ empty.innerHTML=
560
+ '<h4>No decision provenance captured yet</h4>'+
561
+ '<p>Future: a journal/ADR hook or <code>xlabs_log_decision</code> integration will populate this trace. Each entry maps an output (commit/file) back through its signal chain &#x2192; gate &#x2192; model &#x2192; cost.</p>';
562
+ provPanel.appendChild(empty);
563
+ } else {
564
+ const tbl=document.createElement('table');
565
+ tbl.setAttribute('aria-label','Decision provenance trace');
566
+ tbl.innerHTML=
567
+ '<thead><tr>'+
568
+ '<th>Output</th>'+
569
+ '<th>Signal chain</th>'+
570
+ '<th>Gate</th>'+
571
+ '<th>Model</th>'+
572
+ '<th>Cost (USD)</th>'+
573
+ '<th>When</th>'+
574
+ '</tr></thead>';
575
+ const tbody=document.createElement('tbody');
576
+ for(const dec of decisions){
577
+ const tr=document.createElement('tr');
578
+ const chain=Array.isArray(dec.signal_chain)?dec.signal_chain.map(s=>esc(s)).join(' &#x2192; '):'—';
579
+ const cost=typeof dec.cost==='number'?'$'+dec.cost.toFixed(4):'—';
580
+ const ts=dec.ts?new Date(dec.ts).toLocaleDateString():'—';
581
+ tr.innerHTML=
582
+ '<td class="mono" style="font-size:11px">'+esc(dec.output||'—')+'</td>'+
583
+ '<td style="font-size:10px;color:var(--accent)">'+chain+'</td>'+
584
+ '<td class="mono" style="font-size:11px">'+esc(dec.gate||'—')+'</td>'+
585
+ '<td class="mono" style="font-size:11px">'+esc(dec.model||'—')+'</td>'+
586
+ '<td class="mono" style="font-size:11px">'+esc(cost)+'</td>'+
587
+ '<td style="font-size:11px;color:var(--text-tertiary)">'+esc(ts)+'</td>';
588
+ tbody.appendChild(tr);
589
+ }
590
+ tbl.appendChild(tbody);
591
+ provPanel.appendChild(tbl);
592
+ }
593
+ sections.appendChild(provPanel);
594
+
595
+ // Apply initial persona
596
+ applyPersona(currentProfile.persona);
597
+ }
598
+
599
+ // ── My Lens (Pro-only persona) ──
600
+ // Renders into #govern-content when the "My Lens" persona is selected.
601
+ // Shows only: cost KPIs, gates fired + blocks, skill-ROI (active/dormant).
602
+ // Data is the SAME real data as other tabs — just curated for personal leverage.
603
+ // XSS: all user data interpolated via esc(); no innerHTML of raw D values.
604
+ function renderMyLens(){
605
+ const container=document.getElementById('govern-content');
606
+ if(!container)return;
607
+
608
+ const gates=D.gates||[];
609
+ const freq=D.skillFrequency||[];
610
+ const ov=D.overview||{};
611
+ const totalInstalled=D.totalInstalledSkills||63;
612
+ const totalFired=gates.reduce((s,g)=>s+(g.fired||0),0);
613
+ const totalBlocks=gates.reduce((s,g)=>s+(g.blocked||0),0);
614
+ const activeSkills=freq.length;
615
+ const dormantCount=Math.max(0,totalInstalled-activeSkills);
616
+
617
+ // Header
618
+ const hdr=document.createElement('div');
619
+ hdr.className='mylens-header';
620
+ const badge=document.createElement('span');
621
+ badge.className='mylens-badge';
622
+ badge.setAttribute('aria-label','Pro feature');
623
+ badge.textContent='Pro';
624
+ const title=document.createElement('span');
625
+ title.className='mylens-title';
626
+ title.textContent='My Lens';
627
+ const sub=document.createElement('span');
628
+ sub.className='mylens-sub';
629
+ sub.textContent='Personal leverage focus';
630
+ hdr.appendChild(badge);
631
+ hdr.appendChild(title);
632
+ hdr.appendChild(sub);
633
+ container.appendChild(hdr);
634
+
635
+ // KPI row (3 cards: gates fired, blocks caught, skills active)
636
+ const kpis=document.createElement('div');
637
+ kpis.className='mylens-kpis';
638
+ kpis.setAttribute('aria-label','Personal usage key metrics');
639
+
640
+ function mkKpi(label,val,sub2,subCls){
641
+ const d=document.createElement('div');
642
+ d.className='kpi';
643
+ d.innerHTML=
644
+ '<div class="k-lbl">'+esc(label)+'</div>'+
645
+ '<div class="k-val" style="font-size:24px">'+esc(String(val==null?'—':val))+'</div>'+
646
+ (sub2?'<div class="k-sub'+(subCls?' '+subCls:'')+'">'+esc(sub2)+'</div>':'');
647
+ return d;
648
+ }
649
+
650
+ kpis.appendChild(mkKpi(
651
+ 'Gates fired',
652
+ totalFired>0?totalFired:'—',
653
+ totalFired>0?gates.length+' gate'+(gates.length!==1?'s':'')+' active':'no gates yet',
654
+ totalFired>0?'up':'',
655
+ ));
656
+ kpis.appendChild(mkKpi(
657
+ 'Blocks caught',
658
+ totalBlocks>0?totalBlocks:'—',
659
+ totalBlocks>0?'real policy violations blocked':'none recorded yet',
660
+ totalBlocks>0?'warn':'',
661
+ ));
662
+ kpis.appendChild(mkKpi(
663
+ 'Skills active',
664
+ activeSkills>0?activeSkills+'/'+totalInstalled:'—',
665
+ activeSkills>0?(dormantCount+' dormant in last '+(D.window_days||30)+'d'):'run skills to track',
666
+ activeSkills>0?(dormantCount>0?'warn':'up'):'',
667
+ ));
668
+ container.appendChild(kpis);
669
+
670
+ // Skill ROI — top used + dormant (reuse Measure-tab data)
671
+ if(freq.length>0){
672
+ const roiPanel=document.createElement('div');
673
+ roiPanel.className='panel section';
674
+ roiPanel.setAttribute('aria-label','Skill ROI — active and dormant');
675
+ roiPanel.innerHTML='<h3>Skill ROI <span class="pill">your window</span></h3>';
676
+
677
+ // All-known list (same as renderMeasure)
678
+ const allKnown=['cook','plan','scout','brainstorm','design','skill-forge','debug','fix','test','review',
679
+ 'db','sentinel','preflight','onboard','deploy','marketing','perf','autopsy','safeguard','surgeon',
680
+ 'audit','incident','review-intake','logic-guardian','ba','docs','mcp-builder','adversary','retro',
681
+ 'graft','improve-architecture','research','docs-seeker','trend-scout','problem-solver',
682
+ 'sequential-thinking','verification','hallucination-guard','completion-gate','constraint-check',
683
+ 'sast','integrity-check','context-engine','context-pack','journal','session-bridge',
684
+ 'neural-memory','worktree','watchdog','scope-guard','browser-pilot','asset-creator',
685
+ 'video-creator','slides','dependency-doctor','git','doc-processor','sentinel-env',
686
+ 'team','launch','rescue','scaffold','skill-router'];
687
+ const activeSet=new Set(freq.map(s=>s.skill));
688
+ const dormantSamples=allKnown.filter(s=>!activeSet.has(s)).slice(0,6);
689
+
690
+ const roi=document.createElement('div');
691
+ roi.className='roi-grid';
692
+
693
+ // Top active
694
+ const topCard=document.createElement('div');
695
+ topCard.className='roi-card';
696
+ const topTitleEl=document.createElement('div');
697
+ topTitleEl.className='roi-card-title';
698
+ topTitleEl.textContent='Your top skills';
699
+ topCard.appendChild(topTitleEl);
700
+ const topChips=document.createElement('div');
701
+ for(const s of freq.slice(0,6)){
702
+ const chip=document.createElement('span');
703
+ chip.className='roi-chip active';
704
+ chip.setAttribute('aria-label',s.skill+': '+s.count+' sessions');
705
+ chip.textContent=s.skill;
706
+ topChips.appendChild(chip);
707
+ }
708
+ topCard.appendChild(topChips);
709
+
710
+ // Dormant sample
711
+ const dormCard=document.createElement('div');
712
+ dormCard.className='roi-card';
713
+ const dormTitleEl=document.createElement('div');
714
+ dormTitleEl.className='roi-card-title';
715
+ dormTitleEl.textContent=dormantCount>0?'Unused this period':'All skills active';
716
+ dormCard.appendChild(dormTitleEl);
717
+ const dormChips=document.createElement('div');
718
+ for(const s of dormantSamples){
719
+ const chip=document.createElement('span');
720
+ chip.className='roi-chip dormant';
721
+ chip.setAttribute('aria-label',s+': not used in window');
722
+ chip.textContent=s;
723
+ dormChips.appendChild(chip);
724
+ }
725
+ if(dormantCount>dormantSamples.length){
726
+ const more=document.createElement('span');
727
+ more.className='roi-chip dormant';
728
+ more.style.fontStyle='italic';
729
+ more.textContent='+'+(dormantCount-dormantSamples.length)+' more';
730
+ dormChips.appendChild(more);
731
+ }
732
+ dormCard.appendChild(dormChips);
733
+
734
+ roi.appendChild(topCard);
735
+ roi.appendChild(dormCard);
736
+ roiPanel.appendChild(roi);
737
+ container.appendChild(roiPanel);
738
+ } else {
739
+ const empty=document.createElement('div');
740
+ empty.className='empty';
741
+ empty.style.padding='24px 10px';
742
+ empty.innerHTML='<h4>No session data yet</h4><p>Run Rune skills to start seeing your personal leverage data here.</p>';
743
+ container.appendChild(empty);
744
+ }
745
+
746
+ // Cost / feature — honest placeholder (not captured)
747
+ const costPanel=document.createElement('div');
748
+ costPanel.className='panel section';
749
+ costPanel.setAttribute('aria-label','Cost metrics');
750
+ costPanel.innerHTML=
751
+ '<h3>Cost / Feature <span class="pill">personal</span></h3>'+
752
+ '<div class="empty" style="padding:18px 10px">'+
753
+ '<h4>Not captured yet</h4>'+
754
+ '<p>Per-feature cost tracking requires session-level cost data. Run sessions with Rune hooks to begin collecting.</p>'+
755
+ '</div>';
756
+ container.appendChild(costPanel);
757
+
758
+ // Update profile status label
759
+ const statusEl=document.getElementById('profile-status-label');
760
+ if(statusEl)statusEl.textContent=PERSONA_DESCRIPTIONS.mylens||'';
761
+ }
762
+
763
+ // ── Measure tab (Phase 5a enrichment) ──
764
+ function renderMeasure(){
765
+ const container=document.getElementById('measure-content');
766
+ if(!container)return;
767
+
768
+ // Canonical list of all Rune core skills — single source of truth for denominator.
769
+ // D.totalInstalledSkills is the server-side embedding of ALL_KNOWN_SKILLS.length,
770
+ // but we also keep the list here for dormant-sample filtering.
771
+ const allKnownSkills=D.allCoreSkillNames||['cook','plan','scout','brainstorm','design','skill-forge','debug','fix','test','review',
772
+ 'db','sentinel','preflight','onboard','deploy','marketing','perf','autopsy','safeguard','surgeon',
773
+ 'audit','incident','review-intake','logic-guardian','ba','docs','mcp-builder','adversary','retro',
774
+ 'graft','improve-architecture','research','docs-seeker','trend-scout','problem-solver',
775
+ 'sequential-thinking','verification','hallucination-guard','completion-gate','constraint-check',
776
+ 'sast','integrity-check','context-engine','context-pack','journal','session-bridge',
777
+ 'neural-memory','worktree','watchdog','scope-guard','browser-pilot','asset-creator',
778
+ 'video-creator','slides','dependency-doctor','git','doc-processor','sentinel-env',
779
+ 'team','launch','rescue','scaffold','skill-router'];
780
+ const totalInstalled=allKnownSkills.length; // single source of truth — never magic 64
781
+
782
+ const freq=D.skillFrequency||[];
783
+ const models=D.modelDistribution||[];
784
+ const ov=D.overview||{};
785
+ const heatmapData=D.skillHeatmap||{heatmap:[],dates:[],maxCount:1};
786
+ const timeline=D.sessionTimeline||[];
787
+
788
+ // ── Row 1: Top Skills (left) + Model Mix (right) ──
789
+ const row1=document.createElement('div');
790
+ row1.className='cols';
791
+
792
+ // Left: top skills frequency bar
793
+ const skillPanel=document.createElement('div');
794
+ skillPanel.className='panel';
795
+ skillPanel.innerHTML='<h3>Top Skills <span class="pill">frequency</span></h3>';
796
+
797
+ if(freq.length===0){
798
+ skillPanel.innerHTML+='<div class="empty" style="padding:24px 10px"><h4>No skill data yet</h4><p>Run Rune skills to start collecting frequency metrics.</p></div>';
799
+ } else {
800
+ const maxCount=freq[0].count||1;
801
+ const barList=document.createElement('div');
802
+ barList.style.marginTop='8px';
803
+ barList.setAttribute('role','list');
804
+ barList.setAttribute('aria-label','Top skills by frequency');
805
+ for(const s of freq.slice(0,15)){
806
+ const row=document.createElement('div');
807
+ row.className='skill-bar-row';
808
+ row.setAttribute('role','listitem');
809
+ const pct=Math.max(4,Math.round((s.count/maxCount)*100));
810
+ const nameEl=document.createElement('div');
811
+ nameEl.className='skill-bar-name';
812
+ nameEl.title=s.skill;
813
+ nameEl.textContent=s.skill;
814
+ const track=document.createElement('div');
815
+ track.className='skill-bar-track';
816
+ track.setAttribute('role','progressbar');
817
+ track.setAttribute('aria-valuenow',String(s.count));
818
+ track.setAttribute('aria-valuemax',String(maxCount));
819
+ track.setAttribute('aria-label',s.skill+': '+s.count+' sessions');
820
+ const fill=document.createElement('div');
821
+ fill.className='skill-bar-fill';
822
+ fill.style.width=pct+'%';
823
+ track.appendChild(fill);
824
+ const countEl=document.createElement('div');
825
+ countEl.className='skill-bar-count';
826
+ countEl.textContent=String(safeInt(s.count));
827
+ row.appendChild(nameEl);row.appendChild(track);row.appendChild(countEl);
828
+ barList.appendChild(row);
829
+ }
830
+ skillPanel.appendChild(barList);
831
+ }
832
+
833
+ // Right: model mix table + session overview
834
+ const rightPanel=document.createElement('div');
835
+ rightPanel.className='panel';
836
+ rightPanel.innerHTML='<h3>Model Mix <span class="pill">usage</span></h3>';
837
+
838
+ if(models.length===0&&!ov.total_sessions){
839
+ rightPanel.innerHTML+='<div class="empty" style="padding:24px 10px"><h4>No analytics data yet</h4><p>Start sessions to populate model usage stats.</p></div>';
840
+ } else {
841
+ if(models.length>0){
842
+ const tbl=document.createElement('table');
843
+ tbl.setAttribute('aria-label','Model distribution');
844
+ tbl.innerHTML='<thead><tr><th>Model</th><th style="text-align:right">Skill calls</th></tr></thead>';
845
+ const tbody=document.createElement('tbody');
846
+ const total=models.reduce((s,m)=>s+(m.skill_count||0),0)||1;
847
+ for(const m of models){
848
+ const tr=document.createElement('tr');
849
+ const pct=total>0?Math.round((m.skill_count||0)/total*100):0;
850
+ const modelCell=document.createElement('td');
851
+ modelCell.className='mono';
852
+ modelCell.textContent=m.model;
853
+ const countCell=document.createElement('td');
854
+ countCell.className='mono';
855
+ countCell.style.textAlign='right';
856
+ countCell.textContent=String(safeInt(m.skill_count))+' ('+pct+'%)';
857
+ tr.appendChild(modelCell);tr.appendChild(countCell);
858
+ tbody.appendChild(tr);
859
+ }
860
+ tbl.appendChild(tbody);
861
+ rightPanel.appendChild(tbl);
862
+ }
863
+ if(ov.total_sessions){
864
+ const stats=document.createElement('div');
865
+ stats.style.cssText='margin-top:14px;display:flex;flex-direction:column;gap:8px;';
866
+ const items=[
867
+ ['Sessions',ov.total_sessions],
868
+ ['Avg duration',ov.avg_duration_min!=null?ov.avg_duration_min+' min':'—'],
869
+ ['Total tool calls',ov.total_tool_calls||0],
870
+ ['Skill invocations',ov.total_skill_invocations||0],
871
+ ['Active days',ov.active_days||0],
872
+ ];
873
+ for(const [label,val] of items){
874
+ const row=document.createElement('div');
875
+ row.style.cssText='display:flex;justify-content:space-between;font-size:12px;';
876
+ const lblEl=document.createElement('span');
877
+ lblEl.style.color='var(--text-tertiary)';
878
+ lblEl.textContent=label;
879
+ const valEl=document.createElement('span');
880
+ valEl.style.cssText='font-family:var(--font-mono);color:var(--text-primary);';
881
+ valEl.textContent=String(val);
882
+ row.appendChild(lblEl);row.appendChild(valEl);
883
+ stats.appendChild(row);
884
+ }
885
+ rightPanel.appendChild(stats);
886
+ }
887
+ }
888
+
889
+ row1.appendChild(skillPanel);
890
+ row1.appendChild(rightPanel);
891
+ container.appendChild(row1);
892
+
893
+ // ── Row 2: Skill-ROI (used vs dormant) ──
894
+ const roiSection=document.createElement('div');
895
+ roiSection.className='section panel';
896
+ roiSection.setAttribute('aria-label','Skill ROI — used versus dormant');
897
+ const roiTitle=document.createElement('h3');
898
+ roiTitle.innerHTML='Skill ROI <span class="pill">coverage</span>';
899
+ roiSection.appendChild(roiTitle);
900
+
901
+ const activeSkills=freq.length; // distinct skills invoked in window
902
+ const dormantCount=Math.max(0,totalInstalled-activeSkills);
903
+
904
+ if(freq.length===0){
905
+ const empty=document.createElement('div');
906
+ empty.className='empty';
907
+ empty.style.padding='24px 10px';
908
+ empty.innerHTML='<h4>No session data yet</h4><p>Run Rune skills to see which are active vs dormant in your window.</p>';
909
+ roiSection.appendChild(empty);
910
+ } else {
911
+ const roiMeta=document.createElement('div');
912
+ roiMeta.style.cssText='margin-bottom:12px;font-size:12px;color:var(--text-secondary);';
913
+ const activeEl=document.createElement('span');
914
+ activeEl.innerHTML='<b style="font-family:var(--font-mono);color:var(--pass)">'+activeSkills+'</b> of <b style="font-family:var(--font-mono)">'+totalInstalled+'</b> Rune core skills active this period &bull; ';
915
+ const dormEl=document.createElement('span');
916
+ dormEl.innerHTML='<b style="font-family:var(--font-mono);color:var(--text-tertiary)">'+dormantCount+'</b> dormant in the last '+safeInt(D.window_days||30)+' days';
917
+ roiMeta.appendChild(activeEl);
918
+ roiMeta.appendChild(dormEl);
919
+ roiSection.appendChild(roiMeta);
920
+
921
+ // Progress bar for ROI coverage
922
+ const coveragePct=totalInstalled>0?Math.round(activeSkills/totalInstalled*100):0;
923
+ const barWrap=document.createElement('div');
924
+ barWrap.style.cssText='height:6px;background:var(--bg-elevated);border-radius:3px;margin-bottom:14px;overflow:hidden;';
925
+ barWrap.setAttribute('role','progressbar');
926
+ barWrap.setAttribute('aria-valuenow',String(coveragePct));
927
+ barWrap.setAttribute('aria-valuemin','0');
928
+ barWrap.setAttribute('aria-valuemax','100');
929
+ barWrap.setAttribute('aria-label',coveragePct+'% of Rune core skills active this period');
930
+ const barFill=document.createElement('div');
931
+ barFill.style.cssText='height:100%;width:'+coveragePct+'%;border-radius:3px;background:var(--accent);';
932
+ barWrap.appendChild(barFill);
933
+ roiSection.appendChild(barWrap);
934
+
935
+ const roiGrid=document.createElement('div');
936
+ roiGrid.className='roi-grid';
937
+
938
+ // Top used
939
+ const topCard=document.createElement('div');
940
+ topCard.className='roi-card';
941
+ const topTitle=document.createElement('div');
942
+ topTitle.className='roi-card-title';
943
+ topTitle.textContent='Top active skills';
944
+ topCard.appendChild(topTitle);
945
+ const topChips=document.createElement('div');
946
+ for(const s of freq.slice(0,8)){
947
+ const chip=document.createElement('span');
948
+ chip.className='roi-chip active';
949
+ chip.setAttribute('aria-label',s.skill+': used '+s.count+' sessions');
950
+ chip.textContent=s.skill;
951
+ topChips.appendChild(chip);
952
+ }
953
+ topCard.appendChild(topChips);
954
+
955
+ // Dormant (Rune core skills not invoked in this window)
956
+ const activeSet=new Set(freq.map(s=>s.skill));
957
+ const dormantSamples=allKnownSkills.filter(s=>!activeSet.has(s)).slice(0,8);
958
+
959
+ const dormCard=document.createElement('div');
960
+ dormCard.className='roi-card';
961
+ const dormTitle=document.createElement('div');
962
+ dormTitle.className='roi-card-title';
963
+ dormTitle.textContent=dormantCount>0?'Dormant skills (sample)':'No dormant skills';
964
+ dormCard.appendChild(dormTitle);
965
+ if(dormantSamples.length>0){
966
+ const dormChips=document.createElement('div');
967
+ for(const s of dormantSamples){
968
+ const chip=document.createElement('span');
969
+ chip.className='roi-chip dormant';
970
+ chip.setAttribute('aria-label',s+': not used in window');
971
+ chip.textContent=s;
972
+ dormChips.appendChild(chip);
973
+ }
974
+ if(dormantCount>dormantSamples.length){
975
+ const more=document.createElement('span');
976
+ more.className='roi-chip dormant';
977
+ more.style.fontStyle='italic';
978
+ more.textContent='+'+(dormantCount-dormantSamples.length)+' more';
979
+ dormChips.appendChild(more);
980
+ }
981
+ dormCard.appendChild(dormChips);
982
+ } else {
983
+ const msg=document.createElement('p');
984
+ msg.style.cssText='font-size:11px;color:var(--text-tertiary);margin-top:4px;';
985
+ msg.textContent='All tracked skills used in this window.';
986
+ dormCard.appendChild(msg);
987
+ }
988
+
989
+ roiGrid.appendChild(topCard);
990
+ roiGrid.appendChild(dormCard);
991
+ roiSection.appendChild(roiGrid);
992
+ }
993
+ container.appendChild(roiSection);
994
+
995
+ // ── Row 3: Activity heatmap (per-day per-skill) ──
996
+ const hmSection=document.createElement('div');
997
+ hmSection.className='section panel';
998
+ hmSection.setAttribute('aria-label','Activity heatmap — skill use by date');
999
+ const hmTitle=document.createElement('h3');
1000
+ hmTitle.innerHTML='Activity Heatmap <span class="pill">per-day</span>';
1001
+ hmSection.appendChild(hmTitle);
1002
+
1003
+ const hm=heatmapData.heatmap||[];
1004
+ const hmDates=heatmapData.dates||[];
1005
+ const hmMax=heatmapData.maxCount||1;
1006
+
1007
+ if(hm.length===0||hmDates.length===0){
1008
+ const empty=document.createElement('div');
1009
+ empty.className='empty';
1010
+ empty.style.padding='24px 10px';
1011
+ empty.innerHTML='<h4>No heatmap data yet</h4><p>Activity data by date will appear here once sessions are recorded. Not captured — no per-day metrics available.</p>';
1012
+ hmSection.appendChild(empty);
1013
+ } else {
1014
+ const hmMeta=document.createElement('p');
1015
+ hmMeta.style.cssText='font-size:11px;color:var(--text-tertiary);margin-bottom:8px;';
1016
+ hmMeta.textContent='Per-day skill invocations — top '+hm.length+' skills, '+hmDates.length+' day'+(hmDates.length!==1?'s':'');
1017
+ hmSection.appendChild(hmMeta);
1018
+
1019
+ const hmWrap=document.createElement('div');
1020
+ hmWrap.className='heatmap-wrap';
1021
+
1022
+ const tbl=document.createElement('table');
1023
+ tbl.className='heatmap-table';
1024
+ tbl.setAttribute('aria-label','Skill activity heatmap by date');
1025
+
1026
+ // Header row — dates (show last 14 max to fit)
1027
+ const showDates=hmDates.slice(-14);
1028
+ const thead=document.createElement('thead');
1029
+ const hdr=document.createElement('tr');
1030
+ const cornerTh=document.createElement('th');
1031
+ cornerTh.textContent='Skill';
1032
+ hdr.appendChild(cornerTh);
1033
+ for(const d of showDates){
1034
+ const th=document.createElement('th');
1035
+ th.style.textAlign='center';
1036
+ // Show MM-DD
1037
+ th.textContent=d.slice(5);
1038
+ hdr.appendChild(th);
1039
+ }
1040
+ thead.appendChild(hdr);
1041
+ tbl.appendChild(thead);
1042
+
1043
+ const tbody=document.createElement('tbody');
1044
+ for(const row of hm){
1045
+ const tr=document.createElement('tr');
1046
+ const nameTd=document.createElement('td');
1047
+ nameTd.className='hm-skill-label';
1048
+ nameTd.title=row.skill;
1049
+ nameTd.textContent=row.skill;
1050
+ tr.appendChild(nameTd);
1051
+ // Only show dates in showDates window
1052
+ const dateMap=new Map(row.days.map(d=>[d.date,d.count]));
1053
+ for(const date of showDates){
1054
+ const count=dateMap.get(date)||0;
1055
+ const td=document.createElement('td');
1056
+ td.style.padding='2px';
1057
+ const cell=document.createElement('div');
1058
+ cell.className='hm-cell';
1059
+ // Opacity 0.08 (empty) → 0.9 (max)
1060
+ const alpha=count===0?0.08:0.15+0.75*(count/hmMax);
1061
+ cell.style.background='rgba(45,212,191,'+alpha.toFixed(2)+')';
1062
+ cell.setAttribute('aria-label',row.skill+' on '+date+': '+count+(count===1?' session':' sessions'));
1063
+ cell.title=date+': '+count;
1064
+ td.appendChild(cell);
1065
+ tr.appendChild(td);
1066
+ }
1067
+ tbody.appendChild(tr);
1068
+ }
1069
+ tbl.appendChild(tbody);
1070
+ hmWrap.appendChild(tbl);
1071
+ hmSection.appendChild(hmWrap);
1072
+ }
1073
+ container.appendChild(hmSection);
1074
+
1075
+ // ── Row 4: Session timeline ──
1076
+ const tlSection=document.createElement('div');
1077
+ tlSection.className='section panel';
1078
+ tlSection.setAttribute('aria-label','Recent session timeline');
1079
+ const tlTitle=document.createElement('h3');
1080
+ tlTitle.innerHTML='Session Timeline <span class="pill">recent</span>';
1081
+ tlSection.appendChild(tlTitle);
1082
+
1083
+ if(timeline.length===0){
1084
+ const empty=document.createElement('div');
1085
+ empty.className='empty';
1086
+ empty.style.padding='24px 10px';
1087
+ empty.innerHTML='<h4>No session timeline yet</h4><p>Recent sessions with their skill sequences will appear here. Not captured — run sessions to populate.</p>';
1088
+ tlSection.appendChild(empty);
1089
+ } else {
1090
+ const tlList=document.createElement('div');
1091
+ tlList.className='timeline-list';
1092
+ tlList.setAttribute('role','list');
1093
+ tlList.setAttribute('aria-label','Recent sessions');
1094
+ for(const session of timeline){
1095
+ const item=document.createElement('div');
1096
+ item.className='timeline-item'+(session.primary_skill&&session.primary_skill!=='unknown'?' primary':'');
1097
+ item.setAttribute('role','listitem');
1098
+ item.setAttribute('aria-label','Session on '+session.date+': '+session.skills_used.length+' skills');
1099
+
1100
+ const dateEl=document.createElement('div');
1101
+ dateEl.className='timeline-date';
1102
+ dateEl.textContent=session.date;
1103
+
1104
+ const skillsEl=document.createElement('div');
1105
+ skillsEl.className='timeline-skills';
1106
+ for(const sk of (session.skills_used||[]).slice(0,8)){
1107
+ const chip=document.createElement('span');
1108
+ chip.className='timeline-skill-chip'+(sk===session.primary_skill?' primary-skill':'');
1109
+ chip.textContent=sk;
1110
+ skillsEl.appendChild(chip);
1111
+ }
1112
+ if((session.skills_used||[]).length>8){
1113
+ const more=document.createElement('span');
1114
+ more.className='timeline-skill-chip';
1115
+ more.style.fontStyle='italic';
1116
+ more.textContent='+'+(session.skills_used.length-8)+' more';
1117
+ skillsEl.appendChild(more);
1118
+ }
1119
+
1120
+ const metaEl=document.createElement('div');
1121
+ metaEl.className='timeline-meta';
1122
+ if(session.duration_min>0){
1123
+ const durEl=document.createElement('div');
1124
+ durEl.textContent=session.duration_min+'min';
1125
+ metaEl.appendChild(durEl);
1126
+ }
1127
+ if(session.tool_calls>0){
1128
+ const tcEl=document.createElement('div');
1129
+ tcEl.style.marginTop='2px';
1130
+ tcEl.textContent=session.tool_calls+' calls';
1131
+ metaEl.appendChild(tcEl);
1132
+ }
1133
+
1134
+ item.appendChild(dateEl);
1135
+ item.appendChild(skillsEl);
1136
+ item.appendChild(metaEl);
1137
+ tlList.appendChild(item);
1138
+ }
1139
+ tlSection.appendChild(tlList);
1140
+ }
1141
+ container.appendChild(tlSection);
1142
+ }
1143
+
1144
+ // ── Improve tab (Phase 5a — data-driven anti-pattern cards) ──
1145
+ function renderImprove(){
1146
+ const container=document.getElementById('improve-content');
1147
+ if(!container)return;
1148
+
1149
+ const gates=D.gates||[];
1150
+ const freq=D.skillFrequency||[];
1151
+ const timeline=D.sessionTimeline||[];
1152
+ const chains=D.skillChains||[];
1153
+ // Use the embedded count (which comes from ALL_KNOWN_SKILLS.length server-side) — never a bare 64
1154
+ const totalInstalled=D.totalInstalledSkills||D.allCoreSkillNames?.length||63;
1155
+ const ov=D.overview||{};
1156
+
1157
+ // ── Derive findings from REAL data only ──
1158
+ // SECURITY: f.body is innerHTML — EVERY string data interpolation MUST be esc()'d; only numbers may be raw.
1159
+ const findings=[];
1160
+
1161
+ // 1. Gate coverage low
1162
+ const GATE_SKILLS=['sentinel','preflight','completion-gate','logic-guardian',
1163
+ 'constraint-check','sast','integrity-check','hallucination-guard'];
1164
+ const activeFiredGates=gates.filter(g=>(g.fired||0)>0).map(g=>g.name);
1165
+ const coverageCount=activeFiredGates.length;
1166
+ if(gates.length>0&&coverageCount<4&&ov.total_sessions>=3){
1167
+ findings.push({
1168
+ icon:'&#9888;',
1169
+ sev:'warn',
1170
+ title:'Low gate coverage',
1171
+ body:'Only '+coverageCount+' of '+GATE_SKILLS.length+' gate skills have fired in the last '+(D.window_days||30)+' days. Gate skills (sentinel, preflight, completion-gate…) catch issues before they reach production.',
1172
+ action:'Add <b>sentinel</b> or <b>preflight</b> to your workflow as pre-commit checks.',
1173
+ });
1174
+ }
1175
+
1176
+ // 2. Blocks caught → link to Govern
1177
+ const totalBlocks=gates.reduce((s,g)=>s+(g.blocked||0),0);
1178
+ if(totalBlocks>0){
1179
+ findings.push({
1180
+ icon:'&#128721;',
1181
+ sev:'warn',
1182
+ title:totalBlocks+' block event'+(totalBlocks!==1?'s':'')+' caught',
1183
+ body:'Your gates have blocked '+totalBlocks+' event'+(totalBlocks!==1?'s':'')+'. These are real policy violations your workflow caught — review them in the Govern tab.',
1184
+ action:'Open <b>Govern → Gate Ledger</b> to see which gate fired and what it stopped.',
1185
+ });
1186
+ }
1187
+
1188
+ // 3. Context bloat — sessions with very high tool_calls
1189
+ const BLOAT_THRESHOLD=120;
1190
+ const bloatSessions=timeline.filter(s=>s.tool_calls>BLOAT_THRESHOLD);
1191
+ if(bloatSessions.length>0){
1192
+ const avg=Math.round(bloatSessions.reduce((s,x)=>s+x.tool_calls,0)/bloatSessions.length);
1193
+ findings.push({
1194
+ icon:'&#128165;',
1195
+ sev:'warn',
1196
+ title:'Context bloat detected in '+bloatSessions.length+' session'+(bloatSessions.length!==1?'s':''),
1197
+ body:bloatSessions.length+' session'+(bloatSessions.length!==1?'s have':' has')+' over '+BLOAT_THRESHOLD+' tool calls (avg '+avg+' in those sessions). Long sessions risk context window exhaustion and model drift.',
1198
+ action:'Use <b>context-engine</b> to checkpoint and compact at logical phase boundaries.',
1199
+ });
1200
+ }
1201
+
1202
+ // 4. Dormant skills (from skill-ROI)
1203
+ const activeSet=new Set(freq.map(s=>s.skill));
1204
+ const dormantCount=Math.max(0,totalInstalled-activeSet.size);
1205
+ if(freq.length>0&&dormantCount>10){
1206
+ findings.push({
1207
+ icon:'&#128712;',
1208
+ sev:'info',
1209
+ title:dormantCount+' Rune core skills unused in this window',
1210
+ body:'Only '+activeSet.size+' of '+totalInstalled+' Rune core skills were invoked in the last '+(D.window_days||30)+' days. Many skills may solve problems you are currently solving manually.',
1211
+ action:'Browse dormant skills (Measure tab) — they may reduce redundant work.',
1212
+ });
1213
+ }
1214
+
1215
+ // 5. Skill-discovery — repeated chain patterns (only if real data)
1216
+ const repeatChains=chains.filter(c=>c.count>=3);
1217
+ if(repeatChains.length>0){
1218
+ const top=repeatChains[0];
1219
+ findings.push({
1220
+ icon:'&#128304;',
1221
+ sev:'info',
1222
+ title:'Repeated workflow pattern detected',
1223
+ body:'The chain <span style="font-family:var(--font-mono);font-size:11px;color:var(--accent)">'+esc(top.chain)+'</span> appeared '+top.count+' time'+(top.count!==1?'s':'')+'. Repeated skill sequences are candidates for a custom skill.',
1224
+ action:'Run <b>skill-forge</b> to codify this pattern into a reusable skill.',
1225
+ });
1226
+ }
1227
+
1228
+ // 6. No gate skills defined at all (not even zero-fired)
1229
+ if(gates.length===0&&ov.total_sessions>0){
1230
+ findings.push({
1231
+ icon:'&#9651;',
1232
+ sev:'info',
1233
+ title:'No gate activity recorded',
1234
+ body:'Sessions are being recorded but no gate skills (sentinel, preflight, etc.) have fired yet. Gate coverage is zero — quality checks are not integrated into the workflow.',
1235
+ action:'Install Rune hooks: <b>rune hooks install --preset gentle</b>.',
1236
+ });
1237
+ }
1238
+
1239
+ // ── Render ──
1240
+ const header=document.createElement('div');
1241
+ header.className='improve-header';
1242
+ const h3=document.createElement('h3');
1243
+ h3.textContent='Improvement Insights';
1244
+ const count=document.createElement('span');
1245
+ count.className='improve-count';
1246
+ count.textContent=findings.length>0?findings.length+' finding'+(findings.length!==1?'s':''):'';
1247
+ header.appendChild(h3);
1248
+ header.appendChild(count);
1249
+ container.appendChild(header);
1250
+
1251
+ const subhead=document.createElement('p');
1252
+ subhead.className='improve-subhead';
1253
+ subhead.textContent='Derived from your real session and governance data. Only findings with evidence are shown — no generic advice.';
1254
+ container.appendChild(subhead);
1255
+
1256
+ if(findings.length===0){
1257
+ const emptyDiv=document.createElement('div');
1258
+ emptyDiv.className='empty';
1259
+ emptyDiv.innerHTML=
1260
+ '<h4>Not enough session data yet</h4>'+
1261
+ '<p>Improvement recommendations are derived from real gate events, session patterns, and skill chains. Run more sessions with Rune skills to surface data-driven insights here.</p>';
1262
+ container.appendChild(emptyDiv);
1263
+ return;
1264
+ }
1265
+
1266
+ const grid=document.createElement('div');
1267
+ grid.className='improve-grid';
1268
+ grid.setAttribute('role','list');
1269
+ grid.setAttribute('aria-label','Improvement findings');
1270
+
1271
+ for(const f of findings){
1272
+ const card=document.createElement('div');
1273
+ card.className='improve-card sev-'+f.sev;
1274
+ card.setAttribute('role','listitem');
1275
+
1276
+ const hdr=document.createElement('div');
1277
+ hdr.className='improve-card-header';
1278
+
1279
+ const iconEl=document.createElement('span');
1280
+ iconEl.className='improve-card-icon';
1281
+ iconEl.setAttribute('aria-hidden','true');
1282
+ iconEl.innerHTML=f.icon;
1283
+
1284
+ const titleEl=document.createElement('span');
1285
+ titleEl.className='improve-card-title';
1286
+ titleEl.textContent=f.title;
1287
+
1288
+ const sevBadge=document.createElement('span');
1289
+ sevBadge.className='improve-sev-badge '+f.sev;
1290
+ sevBadge.setAttribute('aria-label','Severity: '+f.sev);
1291
+ sevBadge.textContent=f.sev;
1292
+
1293
+ hdr.appendChild(iconEl);
1294
+ hdr.appendChild(titleEl);
1295
+ hdr.appendChild(sevBadge);
1296
+ card.appendChild(hdr);
1297
+
1298
+ const body=document.createElement('div');
1299
+ body.className='improve-card-body';
1300
+ // f.body may contain safe inline HTML (font-family span for chain display) — the chain text is escaped via esc()
1301
+ body.innerHTML=f.body;
1302
+ card.appendChild(body);
1303
+
1304
+ if(f.action){
1305
+ const action=document.createElement('div');
1306
+ action.className='improve-card-action';
1307
+ // f.action uses <b> tags only, no interpolated user data
1308
+ action.innerHTML='<span>&#9654;</span><span>'+f.action+'</span>';
1309
+ card.appendChild(action);
1310
+ }
1311
+
1312
+ grid.appendChild(card);
1313
+ }
1314
+
1315
+ container.appendChild(grid);
1316
+ }
1317
+
1318
+ // ── Understand tab: module graph (Phase 4) ──
1319
+ // Features: filters, domain view, guided tour, node inspector, export, search
1320
+ let understandRendered=false;
1321
+ function renderUnderstandGraph(){
1322
+ if(understandRendered)return;
1323
+ understandRendered=true;
1324
+
1325
+ const container=document.getElementById('understand-content');
1326
+ if(!container)return;
1327
+
1328
+ // ── Data normalisation ──
1329
+ const rawModules=Array.isArray(D.modules)?D.modules:[];
1330
+ const rawEdges=Array.isArray(D.edges)?D.edges:[];
1331
+ const rawDomains=Array.isArray(D.domains)?D.domains:[];
1332
+ const skillMesh=D.skillMesh||{nodes:[],edges:[]};
1333
+
1334
+ // Prefer comprehension modules; fall back to skillMesh nodes (labelled as Rune skill mesh)
1335
+ const useComprehension=rawModules.length>0;
1336
+ const isMeshFallback=!useComprehension;
1337
+
1338
+ // Build canonical nodes/edges arrays
1339
+ const allNodes=useComprehension
1340
+ ? rawModules.map(m=>({
1341
+ id:String(m.id||m.name||''),
1342
+ name:String(m.name||m.id||''),
1343
+ layer:String(m.layer||'module'),
1344
+ type:String(m.type||'module'),
1345
+ complexity:String(m.complexity||''),
1346
+ summary:String(m.summary||''),
1347
+ files:typeof m.files==='number'?m.files:null,
1348
+ }))
1349
+ : (skillMesh.nodes||[]).map(n=>({
1350
+ id:String(n.id||n.name||''),
1351
+ name:String(n.id||n.name||''),
1352
+ layer:String(n.layer||'L3'),
1353
+ type:'skill',
1354
+ complexity:'',
1355
+ summary:String(n.summary||''),
1356
+ files:null,
1357
+ }));
1358
+
1359
+ const allRawEdges=useComprehension
1360
+ ? rawEdges.map(e=>({source:String(e.from||''),target:String(e.to||''),category:String(e.category||'structural')}))
1361
+ : (skillMesh.edges||[]).map(e=>({source:String(e.source||''),target:String(e.target||''),category:'structural'}));
1362
+
1363
+ // ── Schema-defined dimensions ──
1364
+ const SCHEMA_TYPES=['file','function','class','module','service','table','endpoint','domain','flow','step','doc','config','skill'];
1365
+ const SCHEMA_CATS=['structural','behavioral','data-flow','dependency','semantic','infrastructure','domain','knowledge'];
1366
+ const SCHEMA_COMPLEXITY=['simple','moderate','complex'];
1367
+
1368
+ // Present values (only for toggles)
1369
+ const presentTypes=[...new Set(allNodes.map(n=>n.type))].filter(t=>SCHEMA_TYPES.includes(t)||t==='skill');
1370
+ const presentCats=[...new Set(allRawEdges.map(e=>e.category))].filter(c=>SCHEMA_CATS.includes(c));
1371
+ const presentComplexity=[...new Set(allNodes.map(n=>n.complexity))].filter(c=>SCHEMA_COMPLEXITY.includes(c));
1372
+
1373
+ // ── Filter state (session-only, no persistence) ──
1374
+ const filterTypes=new Set(presentTypes);
1375
+ const filterCats=new Set(presentCats);
1376
+ const filterComplexity=new Set(SCHEMA_COMPLEXITY); // all on by default (incl. empty/uncategorised)
1377
+
1378
+ // ── View state ──
1379
+ let isDomainView=false;
1380
+ let tourActive=false;
1381
+ let tourStep=0;
1382
+ let selectedNode=null;
1383
+ let searchQuery='';
1384
+ let searchMatches=new Set(); // node ids matching search
1385
+
1386
+ // ── Layer / type colour map ──
1387
+ const NODE_COLORS={
1388
+ file:'#2dd4bf',function:'#2dd4bf',class:'#60a5fa',module:'#2dd4bf',
1389
+ service:'#60a5fa',table:'#a78bfa',endpoint:'#38bdf8',
1390
+ domain:'#fbbf24',flow:'#f97316',step:'#f59e0b',
1391
+ doc:'#f472b6',config:'#94a3b8',concept:'#34d399',skill:'#60a5fa',
1392
+ // layer fallbacks
1393
+ code:'#2dd4bf',data:'#a78bfa',docs:'#f472b6',infra:'#38bdf8',
1394
+ api:'#60a5fa',
1395
+ L0:'#fbbf24',L1:'#f97316',L2:'#60a5fa',L3:'#a78bfa',L4:'#34d399',
1396
+ };
1397
+ const EDGE_CAT_COLORS={
1398
+ structural:'rgba(45,212,191,.35)',
1399
+ behavioral:'rgba(96,165,250,.35)',
1400
+ 'data-flow':'rgba(167,139,250,.35)',
1401
+ dependency:'rgba(148,163,184,.25)',
1402
+ semantic:'rgba(244,114,182,.35)',
1403
+ infrastructure:'rgba(56,189,248,.35)',
1404
+ domain:'rgba(251,191,36,.35)',
1405
+ knowledge:'rgba(52,211,153,.35)',
1406
+ };
1407
+ const COMPLEXITY_RADIUS={simple:5,moderate:7,complex:9,'':6};
1408
+
1409
+ // ── Compute node colour by type then layer ──
1410
+ function nodeColor(n){return NODE_COLORS[n.type]||NODE_COLORS[n.layer]||'#94a3b8';}
1411
+
1412
+ // ── Topological sort for guided tour ──
1413
+ function topoSort(nodes,edges){
1414
+ const ids=nodes.map(n=>n.id);
1415
+ const idSet=new Set(ids);
1416
+ const indeg=new Map(ids.map(id=>[id,0]));
1417
+ const adj=new Map(ids.map(id=>[id,[]]));
1418
+ for(const e of edges){
1419
+ if(idSet.has(e.source)&&idSet.has(e.target)){
1420
+ adj.get(e.source).push(e.target);
1421
+ indeg.set(e.target,(indeg.get(e.target)||0)+1);
1422
+ }
1423
+ }
1424
+ const queue=ids.filter(id=>indeg.get(id)===0);
1425
+ const result=[];
1426
+ while(queue.length>0){
1427
+ const cur=queue.shift();
1428
+ result.push(cur);
1429
+ for(const nb of (adj.get(cur)||[])){
1430
+ const d=(indeg.get(nb)||1)-1;
1431
+ indeg.set(nb,d);
1432
+ if(d===0)queue.push(nb);
1433
+ }
1434
+ }
1435
+ // Append any remaining (cycles)
1436
+ for(const id of ids){if(!result.includes(id))result.push(id);}
1437
+ return result.map(id=>nodes.find(n=>n.id===id)).filter(Boolean);
1438
+ }
1439
+
1440
+ // ── Compute filtered views ──
1441
+ function getFilteredNodes(){
1442
+ return allNodes.filter(n=>{
1443
+ if(presentTypes.length>0&&!filterTypes.has(n.type))return false;
1444
+ const cx=n.complexity||'';
1445
+ // If complexity filter active and node has a known complexity value, filter it
1446
+ if(presentComplexity.length>0&&cx&&!filterComplexity.has(cx))return false;
1447
+ // Search filter
1448
+ if(searchQuery){
1449
+ const q=searchQuery.toLowerCase();
1450
+ return n.name.toLowerCase().includes(q)||n.summary.toLowerCase().includes(q)||n.type.toLowerCase().includes(q);
1451
+ }
1452
+ return true;
1453
+ });
1454
+ }
1455
+ function getFilteredEdges(filteredNodeIds){
1456
+ const idSet=new Set(filteredNodeIds);
1457
+ return allRawEdges.filter(e=>{
1458
+ if(!idSet.has(e.source)||!idSet.has(e.target))return false;
1459
+ if(presentCats.length>0&&!filterCats.has(e.category))return false;
1460
+ return true;
1461
+ });
1462
+ }
1463
+
1464
+ // ── Build full outer layout ──
1465
+ const section=document.createElement('div');
1466
+ section.className='panel';
1467
+
1468
+ // Title row
1469
+ const titleRow=document.createElement('h3');
1470
+ titleRow.innerHTML=(useComprehension
1471
+ ?'Codebase Map'
1472
+ :'Rune Skill Mesh'
1473
+ )+' <span class="pill">understand</span>'
1474
+ +(isMeshFallback?' <span class="pill" style="background:rgba(148,163,184,.1);color:var(--text-tertiary)">mesh fallback</span>':'');
1475
+ section.appendChild(titleRow);
1476
+
1477
+ // Empty state
1478
+ if(allNodes.length===0){
1479
+ const empty=document.createElement('div');
1480
+ empty.className='empty';
1481
+ empty.innerHTML='<h4>No codebase graph yet</h4><p>Run <code>rune onboard</code> or <code>rune autopsy</code> to generate comprehension.json and populate this graph.</p>';
1482
+ section.appendChild(empty);
1483
+ container.appendChild(section);
1484
+ return;
1485
+ }
1486
+
1487
+ // Two-column layout: filters | main
1488
+ const layout=document.createElement('div');
1489
+ layout.className='u-layout';
1490
+
1491
+ // ── LEFT: Filter panel ──
1492
+ const filters=document.createElement('aside');
1493
+ filters.className='u-filters';
1494
+ filters.setAttribute('aria-label','Graph filter controls');
1495
+
1496
+ function mkFilterGroup(label,items,stateSet,onChange){
1497
+ const grp=document.createElement('div');
1498
+ grp.className='u-filter-group';
1499
+ const hdr=document.createElement('div');
1500
+ hdr.className='u-filter-group-header';
1501
+ const lbl=document.createElement('span');
1502
+ lbl.className='u-filter-group-label';
1503
+ lbl.textContent=label;
1504
+ hdr.appendChild(lbl);
1505
+ const btns=document.createElement('span');
1506
+ btns.className='u-quick-btns';
1507
+ const allBtn=document.createElement('button');
1508
+ allBtn.className='u-quick-btn';
1509
+ allBtn.textContent='All';
1510
+ allBtn.setAttribute('aria-label','Select all '+label);
1511
+ allBtn.addEventListener('click',()=>{items.forEach(v=>stateSet.add(v));onChange();renderCheckboxes();});
1512
+ const noneBtn=document.createElement('button');
1513
+ noneBtn.className='u-quick-btn';
1514
+ noneBtn.textContent='None';
1515
+ noneBtn.setAttribute('aria-label','Deselect all '+label);
1516
+ noneBtn.addEventListener('click',()=>{stateSet.clear();onChange();renderCheckboxes();});
1517
+ btns.appendChild(allBtn);btns.appendChild(noneBtn);
1518
+ hdr.appendChild(btns);
1519
+ grp.appendChild(hdr);
1520
+
1521
+ const checkWrap=document.createElement('div');
1522
+ checkWrap.className='u-checks';
1523
+
1524
+ function renderCheckboxes(){
1525
+ checkWrap.innerHTML='';
1526
+ for(const v of items){
1527
+ const row=document.createElement('label');
1528
+ row.className='u-check-row';
1529
+ const cb=document.createElement('input');
1530
+ cb.type='checkbox';
1531
+ cb.checked=stateSet.has(v);
1532
+ cb.setAttribute('aria-label',v);
1533
+ cb.addEventListener('change',()=>{
1534
+ if(cb.checked)stateSet.add(v);else stateSet.delete(v);
1535
+ onChange();
1536
+ });
1537
+ const span=document.createElement('span');
1538
+ span.className='u-check-label';
1539
+ span.textContent=v;
1540
+ // Count badge
1541
+ const badge=document.createElement('span');
1542
+ badge.className='u-badge';
1543
+ const cnt=label==='Node types'
1544
+ ?allNodes.filter(n=>n.type===v).length
1545
+ :label==='Edge types'
1546
+ ?allRawEdges.filter(e=>e.category===v).length
1547
+ :allNodes.filter(n=>n.complexity===v).length;
1548
+ badge.textContent=String(cnt);
1549
+ row.appendChild(cb);row.appendChild(span);row.appendChild(badge);
1550
+ checkWrap.appendChild(row);
1551
+ }
1552
+ }
1553
+ renderCheckboxes();
1554
+ grp.appendChild(checkWrap);
1555
+ // Store ref for external re-render
1556
+ grp._renderCheckboxes=renderCheckboxes;
1557
+ return grp;
1558
+ }
1559
+
1560
+ const filterGroups=[];
1561
+ if(presentTypes.length>0){
1562
+ filterGroups.push(mkFilterGroup('Node types',presentTypes,filterTypes,applyFilters));
1563
+ }
1564
+ if(presentCats.length>0){
1565
+ filterGroups.push(mkFilterGroup('Edge types',presentCats,filterCats,applyFilters));
1566
+ }
1567
+ if(presentComplexity.length>0){
1568
+ filterGroups.push(mkFilterGroup('Complexity',presentComplexity,filterComplexity,applyFilters));
1569
+ }
1570
+ for(const g of filterGroups)filters.appendChild(g);
1571
+ layout.appendChild(filters);
1572
+
1573
+ // ── RIGHT: Main panel ──
1574
+ const main=document.createElement('div');
1575
+ main.className='u-main';
1576
+
1577
+ // ── Toolbar ──
1578
+ const toolbar=document.createElement('div');
1579
+ toolbar.className='u-toolbar';
1580
+
1581
+ // Search
1582
+ const searchWrap=document.createElement('div');
1583
+ searchWrap.className='u-search-wrap';
1584
+ const searchIcon=document.createElement('span');
1585
+ searchIcon.className='u-search-icon';
1586
+ searchIcon.setAttribute('aria-hidden','true');
1587
+ searchIcon.textContent='⌕';
1588
+ const searchInput=document.createElement('input');
1589
+ searchInput.type='search';
1590
+ searchInput.className='u-search';
1591
+ searchInput.placeholder='Search nodes…';
1592
+ searchInput.setAttribute('aria-label','Search nodes by name or type');
1593
+ let searchDebounce=null;
1594
+ searchInput.addEventListener('input',()=>{
1595
+ clearTimeout(searchDebounce);
1596
+ searchDebounce=setTimeout(()=>{
1597
+ searchQuery=searchInput.value.trim();
1598
+ applyFilters();
1599
+ },200);
1600
+ });
1601
+ searchWrap.appendChild(searchIcon);
1602
+ searchWrap.appendChild(searchInput);
1603
+ toolbar.appendChild(searchWrap);
1604
+
1605
+ // Match count
1606
+ const matchCount=document.createElement('span');
1607
+ matchCount.className='u-search-match-count';
1608
+ matchCount.setAttribute('aria-live','polite');
1609
+ toolbar.appendChild(matchCount);
1610
+
1611
+ // Domain view toggle
1612
+ const domainBtn=document.createElement('button');
1613
+ domainBtn.className='u-toggle-btn';
1614
+ domainBtn.textContent='Domain View';
1615
+ domainBtn.setAttribute('aria-pressed','false');
1616
+ domainBtn.addEventListener('click',()=>{
1617
+ isDomainView=!isDomainView;
1618
+ domainBtn.classList.toggle('active',isDomainView);
1619
+ domainBtn.setAttribute('aria-pressed',isDomainView?'true':'false');
1620
+ tourBtn.style.display=isDomainView?'none':'';
1621
+ graphArea.style.display=isDomainView?'none':'';
1622
+ legendWrap.style.display=isDomainView?'none':'';
1623
+ domainViewEl.style.display=isDomainView?'':'none';
1624
+ if(isDomainView)renderDomainView();
1625
+ });
1626
+ if(rawDomains.length===0)domainBtn.style.opacity='.4';
1627
+ toolbar.appendChild(domainBtn);
1628
+
1629
+ // Tour button
1630
+ const tourBtn=document.createElement('button');
1631
+ tourBtn.className='u-toggle-btn';
1632
+ tourBtn.textContent='Start Tour';
1633
+ tourBtn.setAttribute('aria-label','Start guided dependency tour');
1634
+ tourBtn.addEventListener('click',()=>{
1635
+ tourPanel.classList.add('visible');
1636
+ tourActive=true;
1637
+ tourStep=0;
1638
+ buildTour();
1639
+ tourBtn.textContent='Exit Tour';
1640
+ tourBtn.classList.add('active');
1641
+ });
1642
+ toolbar.appendChild(tourBtn);
1643
+
1644
+ // Export group
1645
+ const exportGroup=document.createElement('div');
1646
+ exportGroup.className='u-export-group';
1647
+
1648
+ const exportPng=document.createElement('button');
1649
+ exportPng.className='u-export-btn';
1650
+ exportPng.textContent='PNG';
1651
+ exportPng.setAttribute('aria-label','Export graph as PNG image');
1652
+ exportPng.addEventListener('click',()=>{
1653
+ const c=document.getElementById('understand-canvas');
1654
+ if(!c)return;
1655
+ c.toBlob(blob=>{
1656
+ if(!blob)return;
1657
+ const a=document.createElement('a');
1658
+ a.href=URL.createObjectURL(blob);
1659
+ a.download='codebase-graph.png';
1660
+ a.click();
1661
+ URL.revokeObjectURL(a.href);
1662
+ },'image/png');
1663
+ });
1664
+
1665
+ const exportSvg=document.createElement('button');
1666
+ exportSvg.className='u-export-btn';
1667
+ exportSvg.textContent='SVG';
1668
+ exportSvg.setAttribute('aria-label','Export graph as SVG vector');
1669
+ exportSvg.addEventListener('click',()=>exportAsSvg());
1670
+
1671
+ const exportJson=document.createElement('button');
1672
+ exportJson.className='u-export-btn';
1673
+ exportJson.textContent='JSON';
1674
+ exportJson.setAttribute('aria-label','Export filtered graph data as JSON');
1675
+ exportJson.addEventListener('click',()=>{
1676
+ const filtered=getFilteredNodes();
1677
+ const filteredEdges=getFilteredEdges(filtered.map(n=>n.id));
1678
+ const blob=new Blob([JSON.stringify({modules:filtered,edges:filteredEdges},null,2)],{type:'application/json'});
1679
+ const a=document.createElement('a');
1680
+ a.href=URL.createObjectURL(blob);
1681
+ a.download='codebase-graph.json';
1682
+ a.click();
1683
+ URL.revokeObjectURL(a.href);
1684
+ });
1685
+
1686
+ exportGroup.appendChild(exportPng);
1687
+ exportGroup.appendChild(exportSvg);
1688
+ exportGroup.appendChild(exportJson);
1689
+ toolbar.appendChild(exportGroup);
1690
+ main.appendChild(toolbar);
1691
+
1692
+ // ── Tour panel ──
1693
+ const tourPanel=document.createElement('div');
1694
+ tourPanel.className='u-tour-panel';
1695
+ tourPanel.setAttribute('role','region');
1696
+ tourPanel.setAttribute('aria-label','Guided tour');
1697
+ let tourNodes=[];
1698
+ let tourStepListEl=null;
1699
+ let tourDetailEl=null;
1700
+ let tourCounterEl=null;
1701
+ let tourPrevBtn=null;
1702
+ let tourNextBtn=null;
1703
+
1704
+ function buildTour(){
1705
+ const filtered=getFilteredNodes();
1706
+ const filteredEdges=getFilteredEdges(filtered.map(n=>n.id));
1707
+ tourNodes=topoSort(filtered,filteredEdges);
1708
+ tourPanel.innerHTML='';
1709
+ if(tourNodes.length===0){
1710
+ tourPanel.innerHTML='<p style="color:var(--text-tertiary);font-size:12px">No nodes to tour with current filters.</p>';
1711
+ return;
1712
+ }
1713
+ const hdr=document.createElement('div');
1714
+ hdr.className='u-tour-header';
1715
+ const title=document.createElement('span');
1716
+ title.textContent='Guided Tour';
1717
+ hdr.appendChild(title);
1718
+ const closeBtn=document.createElement('button');
1719
+ closeBtn.className='u-tour-close';
1720
+ closeBtn.textContent='✕';
1721
+ closeBtn.setAttribute('aria-label','Close tour');
1722
+ closeBtn.addEventListener('click',closeTour);
1723
+ hdr.appendChild(closeBtn);
1724
+ tourPanel.appendChild(hdr);
1725
+
1726
+ const body=document.createElement('div');
1727
+ body.className='u-tour-body';
1728
+
1729
+ tourStepListEl=document.createElement('div');
1730
+ tourStepListEl.className='u-tour-steps';
1731
+ tourStepListEl.setAttribute('role','list');
1732
+ tourNodes.forEach((n,i)=>{
1733
+ const item=document.createElement('button');
1734
+ item.className='u-tour-step-item'+(i===0?' active':'');
1735
+ item.textContent=(i+1)+'. '+n.name.slice(0,18);
1736
+ item.setAttribute('role','listitem');
1737
+ item.setAttribute('tabindex','0');
1738
+ item.addEventListener('click',()=>{tourStep=i;updateTourStep();});
1739
+ item.setAttribute('aria-label','Step '+(i+1)+': '+n.name);
1740
+ tourStepListEl.appendChild(item);
1741
+ });
1742
+ body.appendChild(tourStepListEl);
1743
+
1744
+ tourDetailEl=document.createElement('div');
1745
+ tourDetailEl.className='u-tour-detail';
1746
+ body.appendChild(tourDetailEl);
1747
+ tourPanel.appendChild(body);
1748
+
1749
+ const nav=document.createElement('div');
1750
+ nav.className='u-tour-nav';
1751
+ tourPrevBtn=document.createElement('button');
1752
+ tourPrevBtn.className='u-tour-nav-btn';
1753
+ tourPrevBtn.textContent='← Prev';
1754
+ tourPrevBtn.setAttribute('aria-label','Previous step');
1755
+ tourPrevBtn.addEventListener('click',()=>{if(tourStep>0){tourStep--;updateTourStep();}});
1756
+ tourNextBtn=document.createElement('button');
1757
+ tourNextBtn.className='u-tour-nav-btn';
1758
+ tourNextBtn.textContent='Next →';
1759
+ tourNextBtn.setAttribute('aria-label','Next step');
1760
+ tourNextBtn.addEventListener('click',()=>{if(tourStep<tourNodes.length-1){tourStep++;updateTourStep();}});
1761
+ tourCounterEl=document.createElement('span');
1762
+ tourCounterEl.className='u-tour-counter';
1763
+ nav.appendChild(tourPrevBtn);nav.appendChild(tourNextBtn);nav.appendChild(tourCounterEl);
1764
+ tourPanel.appendChild(nav);
1765
+ updateTourStep();
1766
+ }
1767
+
1768
+ function updateTourStep(){
1769
+ if(!tourNodes.length||!tourDetailEl)return;
1770
+ const n=tourNodes[tourStep];
1771
+ // Update step list highlight
1772
+ if(tourStepListEl){
1773
+ Array.from(tourStepListEl.children).forEach((el,i)=>{
1774
+ el.classList.toggle('active',i===tourStep);
1775
+ });
1776
+ }
1777
+ // Detail
1778
+ tourDetailEl.innerHTML='';
1779
+ const lbl=document.createElement('div');
1780
+ lbl.className='u-tour-step-label';
1781
+ lbl.textContent=esc(n.name);
1782
+ const summary=document.createElement('div');
1783
+ summary.className='u-tour-step-summary';
1784
+ summary.textContent=n.summary||('A '+esc(n.type)+' in the '+esc(n.layer)+' layer.');
1785
+ tourDetailEl.appendChild(lbl);
1786
+ tourDetailEl.appendChild(summary);
1787
+ // Meta chips
1788
+ if(n.type||n.complexity){
1789
+ const meta=document.createElement('div');
1790
+ meta.style.cssText='margin-top:6px;display:flex;gap:6px;flex-wrap:wrap;';
1791
+ if(n.type){
1792
+ const chip=document.createElement('span');
1793
+ chip.className='pill';
1794
+ chip.textContent=esc(n.type);
1795
+ meta.appendChild(chip);
1796
+ }
1797
+ if(n.complexity){
1798
+ const c=document.createElement('span');
1799
+ c.className='u-complexity-badge '+n.complexity;
1800
+ c.textContent=esc(n.complexity);
1801
+ meta.appendChild(c);
1802
+ }
1803
+ tourDetailEl.appendChild(meta);
1804
+ }
1805
+ if(tourPrevBtn)tourPrevBtn.disabled=tourStep===0;
1806
+ if(tourNextBtn)tourNextBtn.disabled=tourStep===tourNodes.length-1;
1807
+ if(tourCounterEl)tourCounterEl.textContent=(tourStep+1)+' / '+tourNodes.length;
1808
+ // Highlight node on canvas
1809
+ highlightNode(n.id);
1810
+ }
1811
+
1812
+ function closeTour(){
1813
+ tourPanel.classList.remove('visible');
1814
+ tourActive=false;
1815
+ tourBtn.textContent='Start Tour';
1816
+ tourBtn.classList.remove('active');
1817
+ highlightNode(null);
1818
+ }
1819
+
1820
+ // Esc to exit tour
1821
+ document.addEventListener('keydown',function onKey(e){
1822
+ if(e.key==='Escape'){
1823
+ if(tourActive)closeTour();
1824
+ if(selectedNode!==null){selectedNode=null;inspectorPanel.classList.remove('visible');highlightNode(null);}
1825
+ }
1826
+ });
1827
+
1828
+ main.appendChild(tourPanel);
1829
+
1830
+ // ── Graph canvas area ──
1831
+ const graphArea=document.createElement('div');
1832
+ graphArea.className='u-graph-wrap';
1833
+ const canvas=document.createElement('canvas');
1834
+ canvas.id='understand-canvas';
1835
+ canvas.setAttribute('role','group');
1836
+ canvas.setAttribute('aria-label','Module dependency graph');
1837
+ graphArea.appendChild(canvas);
1838
+ main.appendChild(graphArea);
1839
+
1840
+ // ── Legend ──
1841
+ const legendWrap=document.createElement('div');
1842
+ legendWrap.className='u-legend';
1843
+ legendWrap.setAttribute('aria-label','Graph legend');
1844
+ main.appendChild(legendWrap);
1845
+
1846
+ function renderLegend(nodes){
1847
+ legendWrap.innerHTML='';
1848
+ const usedTypes=[...new Set(nodes.map(n=>n.type))].slice(0,8);
1849
+ for(const t of usedTypes){
1850
+ const color=NODE_COLORS[t]||'#94a3b8';
1851
+ legendWrap.innerHTML+='<span aria-label="'+esc(t)+' nodes"><i style="background:'+color+'" aria-hidden="true"></i>'+esc(t)+'</span>';
1852
+ }
1853
+ }
1854
+
1855
+ // ── Node inspector panel ──
1856
+ const inspectorPanel=document.createElement('div');
1857
+ inspectorPanel.className='u-inspector';
1858
+ inspectorPanel.setAttribute('role','complementary');
1859
+ inspectorPanel.setAttribute('aria-label','Node inspector');
1860
+ main.appendChild(inspectorPanel);
1861
+
1862
+ function showInspector(node,filtEdges){
1863
+ inspectorPanel.innerHTML='';
1864
+ inspectorPanel.classList.add('visible');
1865
+ const hdr=document.createElement('h4');
1866
+ hdr.textContent=esc(node.name);
1867
+ const close=document.createElement('button');
1868
+ close.className='u-inspector-close';
1869
+ close.textContent='✕';
1870
+ close.setAttribute('aria-label','Close inspector');
1871
+ close.addEventListener('click',()=>{
1872
+ inspectorPanel.classList.remove('visible');
1873
+ selectedNode=null;
1874
+ highlightNode(null);
1875
+ });
1876
+ hdr.appendChild(close);
1877
+ inspectorPanel.appendChild(hdr);
1878
+
1879
+ function row(label,val){
1880
+ if(!val&&val!==0)return;
1881
+ const d=document.createElement('div');
1882
+ d.className='u-insp-row';
1883
+ const l=document.createElement('span');l.className='u-insp-label';l.textContent=label;
1884
+ const v=document.createElement('span');v.className='u-insp-val';v.textContent=String(val);
1885
+ d.appendChild(l);d.appendChild(v);
1886
+ inspectorPanel.appendChild(d);
1887
+ }
1888
+ row('Type',node.type);
1889
+ row('Layer',node.layer);
1890
+ row('Files',node.files!=null?node.files:null);
1891
+ // Complexity badge
1892
+ if(node.complexity){
1893
+ const d=document.createElement('div');d.className='u-insp-row';
1894
+ const l=document.createElement('span');l.className='u-insp-label';l.textContent='Complexity';
1895
+ const b=document.createElement('span');b.className='u-complexity-badge '+node.complexity;b.textContent=node.complexity;
1896
+ d.appendChild(l);d.appendChild(b);
1897
+ inspectorPanel.appendChild(d);
1898
+ }
1899
+ if(node.summary){
1900
+ const s=document.createElement('div');
1901
+ s.className='u-insp-summary';
1902
+ s.textContent=esc(node.summary);
1903
+ inspectorPanel.appendChild(s);
1904
+ }
1905
+ // Relationships
1906
+ const rels=filtEdges.filter(e=>e.source===node.id||e.target===node.id);
1907
+ if(rels.length>0){
1908
+ const relSec=document.createElement('div');
1909
+ relSec.className='u-insp-relations';
1910
+ const rLbl=document.createElement('div');
1911
+ rLbl.className='u-insp-relations-label';
1912
+ rLbl.textContent='Relationships ('+rels.length+')';
1913
+ relSec.appendChild(rLbl);
1914
+ for(const e of rels.slice(0,12)){
1915
+ const isOut=e.source===node.id;
1916
+ const otherId=isOut?e.target:e.source;
1917
+ const other=allNodes.find(n=>n.id===otherId);
1918
+ const item=document.createElement('div');
1919
+ item.className='u-rel-item';
1920
+ const dir=document.createElement('span');
1921
+ dir.className='u-rel-dir';
1922
+ dir.setAttribute('aria-label',isOut?'outgoing':'incoming');
1923
+ dir.textContent=isOut?'→':'←';
1924
+ const name=document.createElement('span');
1925
+ name.textContent=esc(other?other.name:otherId);
1926
+ name.style.flex='1';
1927
+ const cat=document.createElement('span');
1928
+ cat.style.cssText='font-size:9px;color:var(--text-tertiary);';
1929
+ cat.textContent=esc(e.category);
1930
+ item.appendChild(dir);item.appendChild(name);item.appendChild(cat);
1931
+ relSec.appendChild(item);
1932
+ }
1933
+ if(rels.length>12){
1934
+ const more=document.createElement('div');
1935
+ more.style.cssText='font-size:10px;color:var(--text-tertiary);padding:4px 0;';
1936
+ more.textContent='+'+(rels.length-12)+' more…';
1937
+ relSec.appendChild(more);
1938
+ }
1939
+ inspectorPanel.appendChild(relSec);
1940
+ }
1941
+ }
1942
+
1943
+ // ── Domain view ──
1944
+ const domainViewEl=document.createElement('div');
1945
+ domainViewEl.className='u-domain-view';
1946
+ domainViewEl.style.display='none';
1947
+ main.appendChild(domainViewEl);
1948
+
1949
+ function renderDomainView(){
1950
+ domainViewEl.innerHTML='';
1951
+ if(rawDomains.length===0){
1952
+ const empty=document.createElement('div');
1953
+ empty.className='empty';
1954
+ empty.style.gridColumn='1/-1';
1955
+ empty.innerHTML='<h4>No domain data</h4><p>Run <code>rune onboard</code> to generate domain/flow/step structure.</p>';
1956
+ domainViewEl.appendChild(empty);
1957
+ return;
1958
+ }
1959
+ for(const domain of rawDomains){
1960
+ const col=document.createElement('div');
1961
+ col.className='u-domain-col';
1962
+ const dName=document.createElement('div');
1963
+ dName.className='u-domain-name';
1964
+ dName.textContent=esc(domain.name||'');
1965
+ col.appendChild(dName);
1966
+ if(domain.summary){
1967
+ const dSum=document.createElement('div');
1968
+ dSum.className='u-domain-summary';
1969
+ dSum.textContent=esc(domain.summary);
1970
+ col.appendChild(dSum);
1971
+ }
1972
+ const flows=Array.isArray(domain.flows)?domain.flows:[];
1973
+ for(const flow of flows){
1974
+ const flowEl=document.createElement('div');
1975
+ flowEl.className='u-flow';
1976
+ const fName=document.createElement('div');
1977
+ fName.className='u-flow-name';
1978
+ fName.textContent=esc(flow.name||'');
1979
+ flowEl.appendChild(fName);
1980
+ const steps=Array.isArray(flow.steps)?flow.steps:[];
1981
+ steps.forEach((step,si)=>{
1982
+ const stepEl=document.createElement('div');
1983
+ stepEl.className='u-step';
1984
+ const num=document.createElement('span');
1985
+ num.className='u-step-num';
1986
+ num.textContent=(si+1)+'.';
1987
+ const txt=document.createElement('span');
1988
+ txt.textContent=esc(String(step));
1989
+ stepEl.appendChild(num);stepEl.appendChild(txt);
1990
+ flowEl.appendChild(stepEl);
1991
+ });
1992
+ col.appendChild(flowEl);
1993
+ }
1994
+ domainViewEl.appendChild(col);
1995
+ }
1996
+ }
1997
+
1998
+ layout.appendChild(main);
1999
+ section.appendChild(layout);
2000
+ container.appendChild(section);
2001
+
2002
+ // ── Canvas graph renderer ──
2003
+ let animFrameId=null;
2004
+ let graphNodes=[];
2005
+ let graphEdges=[];
2006
+ let hovered=null;
2007
+ let highlighted=null; // node id to highlight (tour / inspector)
2008
+ let kbFocusIdx=-1; // hoisted here so drawGraph can reference it without a TDZ typeof guard
2009
+ const ctx=canvas.getContext('2d');
2010
+ const reducedMotion=matchMedia('(prefers-reduced-motion:reduce)').matches;
2011
+
2012
+ function layoutNodes(nodes,W,H){
2013
+ const N=nodes.length;
2014
+ if(N===0)return;
2015
+ const cx=W/2,cy=H/2;
2016
+ // Layer-clustered circular layout
2017
+ const layerGroups=new Map();
2018
+ for(const n of nodes){
2019
+ const l=n.layer||'module';
2020
+ if(!layerGroups.has(l))layerGroups.set(l,[]);
2021
+ layerGroups.get(l).push(n);
2022
+ }
2023
+ const layerList=[...layerGroups.keys()];
2024
+ const rBase=Math.min(W,H)*0.34;
2025
+ let globalIdx=0;
2026
+ layerList.forEach((layer,li)=>{
2027
+ const members=layerGroups.get(layer);
2028
+ members.forEach((n,mi)=>{
2029
+ const baseAngle=(li/layerList.length)*Math.PI*2-Math.PI/2;
2030
+ const spread=(members.length>1?(mi/(members.length-1)-0.5)*0.6:0);
2031
+ const angle=baseAngle+spread;
2032
+ const r=rBase*(0.55+0.45*(globalIdx/Math.max(N-1,1)));
2033
+ n.x=cx+Math.cos(angle)*r;
2034
+ n.y=cy+Math.sin(angle)*r;
2035
+ n.color=nodeColor(n);
2036
+ n.radius=COMPLEXITY_RADIUS[n.complexity||'']||6;
2037
+ globalIdx++;
2038
+ });
2039
+ });
2040
+ }
2041
+
2042
+ function drawGraph(ts){
2043
+ const W=canvas.width/(window.devicePixelRatio||1);
2044
+ const H=canvas.height/(window.devicePixelRatio||1);
2045
+ const dpr=window.devicePixelRatio||1;
2046
+ ctx.save();ctx.scale(dpr,dpr);
2047
+ ctx.clearRect(0,0,W,H);
2048
+ const t=reducedMotion?0:(ts||0)*0.001;
2049
+
2050
+ const nodeMap=new Map(graphNodes.map((n,i)=>[n.id,i]));
2051
+
2052
+ // Edges
2053
+ for(const e of graphEdges){
2054
+ const ai=nodeMap.get(e.source);
2055
+ const bi=nodeMap.get(e.target);
2056
+ if(ai==null||bi==null)continue;
2057
+ const a=graphNodes[ai],b=graphNodes[bi];
2058
+ const hiSrc=hovered!=null&&(nodeMap.get(e.source)===hovered||nodeMap.get(e.target)===hovered);
2059
+ const hiHighlight=highlighted&&(e.source===highlighted||e.target===highlighted);
2060
+ const hi=hiSrc||hiHighlight;
2061
+ const color=hi?(EDGE_CAT_COLORS[e.category]||'rgba(45,212,191,.5)'):('rgba(148,163,184,.10)');
2062
+ ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);
2063
+ ctx.strokeStyle=color;
2064
+ ctx.lineWidth=hi?1.8:0.7;
2065
+ ctx.stroke();
2066
+ }
2067
+
2068
+ // Nodes
2069
+ for(let i=0;i<graphNodes.length;i++){
2070
+ const n=graphNodes[i];
2071
+ const isH=hovered===i;
2072
+ const isHighlighted=highlighted===n.id;
2073
+ const isConnected=hovered!=null&&!isH&&graphEdges.some(e=>{
2074
+ const si=nodeMap.get(e.source),ti=nodeMap.get(e.target);
2075
+ return si===hovered&&ti===i||ti===hovered&&si===i;
2076
+ });
2077
+ const isSearch=searchQuery&&searchMatches.has(n.id);
2078
+ const dim=hovered!=null&&!isH&&!isConnected&&!isHighlighted;
2079
+ const pulse=reducedMotion?1:(isH||isHighlighted?1.3:(1+0.07*Math.sin(t*0.7+i*0.9)));
2080
+ const rad=n.radius*pulse;
2081
+
2082
+ // Search ring
2083
+ if(isSearch&&!dim){
2084
+ ctx.beginPath();ctx.arc(n.x,n.y,rad+5,0,Math.PI*2);
2085
+ ctx.strokeStyle='rgba(255,255,255,.5)';ctx.lineWidth=1.5;ctx.stroke();
2086
+ }
2087
+
2088
+ // Tour/inspector highlight ring
2089
+ if(isHighlighted){
2090
+ ctx.beginPath();ctx.arc(n.x,n.y,rad+7,0,Math.PI*2);
2091
+ ctx.strokeStyle='rgba(45,212,191,.8)';ctx.lineWidth=2;ctx.stroke();
2092
+ }
2093
+
2094
+ if(!dim){
2095
+ // Glow
2096
+ const grd=ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,rad*2.8);
2097
+ grd.addColorStop(0,n.color+(isH||isHighlighted?'50':'18'));
2098
+ grd.addColorStop(1,n.color+'00');
2099
+ ctx.fillStyle=grd;ctx.beginPath();ctx.arc(n.x,n.y,rad*2.8,0,Math.PI*2);ctx.fill();
2100
+ }
2101
+
2102
+ ctx.globalAlpha=dim?0.2:0.9;
2103
+ ctx.fillStyle=dim?'#334155':n.color;
2104
+ ctx.beginPath();ctx.arc(n.x,n.y,rad,0,Math.PI*2);ctx.fill();
2105
+ ctx.globalAlpha=1;
2106
+
2107
+ // Label
2108
+ if(isH||isHighlighted||n.radius>=9){
2109
+ ctx.font='500 9px system-ui,sans-serif';
2110
+ ctx.fillStyle=dim?'transparent':'#cbd5e1';
2111
+ ctx.textAlign='center';
2112
+ ctx.fillText(n.name.slice(0,16),n.x,n.y+rad+11);
2113
+ }
2114
+ }
2115
+
2116
+ // ── Keyboard focus ring (drawn last, always on top) ──
2117
+ if(kbFocusIdx>=0&&kbFocusIdx<graphNodes.length){
2118
+ const fn=graphNodes[kbFocusIdx];
2119
+ if(fn&&fn.x!=null&&fn.y!=null){
2120
+ const fnRad=(fn.radius||6);
2121
+ ctx.beginPath();
2122
+ ctx.arc(fn.x,fn.y,fnRad+10,0,Math.PI*2);
2123
+ ctx.strokeStyle='rgba(45,212,191,.95)';
2124
+ ctx.lineWidth=2.5;
2125
+ ctx.setLineDash([4,3]);
2126
+ ctx.stroke();
2127
+ ctx.setLineDash([]);
2128
+ ctx.font='bold 10px system-ui,sans-serif';
2129
+ ctx.fillStyle='#f1f5f9';
2130
+ ctx.textAlign='center';
2131
+ ctx.fillText(fn.name.slice(0,20),fn.x,fn.y+fnRad+24);
2132
+ }
2133
+ }
2134
+
2135
+ ctx.restore();
2136
+ if(!reducedMotion)animFrameId=requestAnimationFrame(drawGraph);
2137
+ }
2138
+
2139
+ function highlightNode(id){
2140
+ highlighted=id;
2141
+ if(reducedMotion)requestAnimationFrame(drawGraph);
2142
+ }
2143
+
2144
+ function resizeCanvas(){
2145
+ if(canvas.parentElement){
2146
+ const dpr=window.devicePixelRatio||1;
2147
+ const W=canvas.parentElement.offsetWidth||600;
2148
+ const H=canvas.parentElement.offsetHeight||320;
2149
+ canvas.width=W*dpr;canvas.height=H*dpr;
2150
+ layoutNodes(graphNodes,W,H);
2151
+ if(reducedMotion)requestAnimationFrame(drawGraph);
2152
+ }
2153
+ }
2154
+
2155
+ // ── Apply filters and re-render ──
2156
+ function applyFilters(){
2157
+ if(animFrameId!=null){cancelAnimationFrame(animFrameId);animFrameId=null;}
2158
+ graphNodes=getFilteredNodes();
2159
+ graphEdges=getFilteredEdges(graphNodes.map(n=>n.id));
2160
+ // Build search match set
2161
+ searchMatches=new Set();
2162
+ if(searchQuery){
2163
+ const q=searchQuery.toLowerCase();
2164
+ for(const n of graphNodes){
2165
+ if(n.name.toLowerCase().includes(q)||n.summary.toLowerCase().includes(q))searchMatches.add(n.id);
2166
+ }
2167
+ }
2168
+ // Match count
2169
+ if(searchQuery){
2170
+ matchCount.textContent=searchMatches.size+' match'+(searchMatches.size!==1?'es':'');
2171
+ }else{
2172
+ matchCount.textContent=graphNodes.length+' node'+(graphNodes.length!==1?'s':'');
2173
+ }
2174
+ // Rebuild tour if open
2175
+ if(tourActive)buildTour();
2176
+ renderLegend(graphNodes);
2177
+ resizeCanvas();
2178
+ if(!reducedMotion){animFrameId=requestAnimationFrame(drawGraph);}
2179
+ else{requestAnimationFrame(drawGraph);}
2180
+ }
2181
+
2182
+ // ── Mouse interactions ──
2183
+ canvas.addEventListener('mousemove',(ev)=>{
2184
+ const rect=canvas.getBoundingClientRect();
2185
+ const mx=ev.clientX-rect.left,my=ev.clientY-rect.top;
2186
+ hovered=null;
2187
+ for(let i=0;i<graphNodes.length;i++){
2188
+ const n=graphNodes[i];
2189
+ const dx=mx-n.x,dy=my-n.y;
2190
+ if(dx*dx+dy*dy<(n.radius+8)**2){hovered=i;break;}
2191
+ }
2192
+ canvas.title=hovered!=null?graphNodes[hovered].name:'';
2193
+ canvas.style.cursor=hovered!=null?'pointer':'crosshair';
2194
+ if(reducedMotion)requestAnimationFrame(drawGraph);
2195
+ });
2196
+
2197
+ canvas.addEventListener('mouseleave',()=>{
2198
+ hovered=null;
2199
+ canvas.title='';
2200
+ canvas.style.cursor='crosshair';
2201
+ if(reducedMotion)requestAnimationFrame(drawGraph);
2202
+ });
2203
+
2204
+ canvas.addEventListener('click',()=>{
2205
+ if(hovered==null)return;
2206
+ const n=graphNodes[hovered];
2207
+ selectedNode=n.id;
2208
+ highlightNode(n.id);
2209
+ showInspector(n,graphEdges);
2210
+ });
2211
+
2212
+ // ── Canvas keyboard accessibility (Phase 5a) ──
2213
+ canvas.setAttribute('tabindex','0');
2214
+ canvas.setAttribute('aria-label','Module dependency graph — Arrow keys to navigate nodes, Enter or Space to inspect, Escape to exit');
2215
+
2216
+ // kbFocusIdx is hoisted above drawGraph so it's in scope without a TDZ typeof guard.
2217
+ function kbFocusNode(idx){
2218
+ kbFocusIdx=idx;
2219
+ highlighted=idx>=0&&idx<graphNodes.length?graphNodes[idx].id:null;
2220
+ if(reducedMotion)requestAnimationFrame(drawGraph);
2221
+ }
2222
+
2223
+ canvas.addEventListener('keydown',(e)=>{
2224
+ const total=graphNodes.length;
2225
+ if(total===0)return;
2226
+
2227
+ // Arrow keys ONLY for intra-graph node cycling — Tab/Shift+Tab must bubble
2228
+ // so keyboard focus can leave the canvas (WCAG 2.1.2 No Keyboard Trap).
2229
+ if(e.key==='ArrowRight'||e.key==='ArrowDown'){
2230
+ e.preventDefault();
2231
+ kbFocusIdx=(kbFocusIdx+1)%total;
2232
+ kbFocusNode(kbFocusIdx);
2233
+ // Announce to sr-only live region
2234
+ const node=graphNodes[kbFocusIdx];
2235
+ if(node&&srLiveEl){srLiveEl.textContent=node.name+', '+node.type+', '+(kbFocusIdx+1)+' of '+total;}
2236
+ } else if(e.key==='ArrowLeft'||e.key==='ArrowUp'){
2237
+ e.preventDefault();
2238
+ kbFocusIdx=(kbFocusIdx-1+total)%total;
2239
+ kbFocusNode(kbFocusIdx);
2240
+ const node=graphNodes[kbFocusIdx];
2241
+ if(node&&srLiveEl){srLiveEl.textContent=node.name+', '+node.type+', '+(kbFocusIdx+1)+' of '+total;}
2242
+ } else if(e.key==='Enter'||e.key===' '){
2243
+ e.preventDefault();
2244
+ if(kbFocusIdx>=0&&kbFocusIdx<total){
2245
+ const n=graphNodes[kbFocusIdx];
2246
+ selectedNode=n.id;
2247
+ highlightNode(n.id);
2248
+ showInspector(n,graphEdges);
2249
+ }
2250
+ } else if(e.key==='Escape'){
2251
+ if(kbFocusIdx>=0){kbFocusIdx=-1;kbFocusNode(-1);}
2252
+ selectedNode=null;
2253
+ inspectorPanel.classList.remove('visible');
2254
+ highlightNode(null);
2255
+ e.stopPropagation(); // prevent double-fire with document-level Esc handler (~line 2637)
2256
+ }
2257
+ });
2258
+
2259
+ canvas.addEventListener('blur',()=>{
2260
+ // Clear kb focus ring when canvas loses focus
2261
+ kbFocusIdx=-1;
2262
+ if(highlighted&&!selectedNode)highlightNode(null);
2263
+ });
2264
+
2265
+ // ── Sr-only live region (ARIA announcements) ──
2266
+ const srLiveEl=document.createElement('div');
2267
+ srLiveEl.setAttribute('aria-live','polite');
2268
+ srLiveEl.setAttribute('aria-atomic','true');
2269
+ srLiveEl.className='sr-only';
2270
+ main.appendChild(srLiveEl);
2271
+
2272
+ // ── Sr-only static node list (screen reader full graph access) ──
2273
+ const srListWrap=document.createElement('div');
2274
+ srListWrap.className='sr-only';
2275
+ srListWrap.setAttribute('aria-label','Graph node list');
2276
+ const srHeading=document.createElement('h4');
2277
+ srHeading.textContent='Graph nodes ('+allNodes.length+' total)';
2278
+ srListWrap.appendChild(srHeading);
2279
+ const srUl=document.createElement('ul');
2280
+ for(const n of allNodes){
2281
+ const li=document.createElement('li');
2282
+ // textContent only — no innerHTML here, safe for any node name
2283
+ li.textContent=n.name+' — '+n.type+(n.layer?' ('+n.layer+')':'');
2284
+ srUl.appendChild(li);
2285
+ }
2286
+ srListWrap.appendChild(srUl);
2287
+ main.appendChild(srListWrap);
2288
+
2289
+ // ── SVG export ──
2290
+ function exportAsSvg(){
2291
+ const W=canvas.width/(window.devicePixelRatio||1)||600;
2292
+ const H=canvas.height/(window.devicePixelRatio||1)||320;
2293
+ const nodeMap=new Map(graphNodes.map((n,i)=>[n.id,i]));
2294
+ let svgLines=['<?xml version="1.0" encoding="UTF-8"?>',
2295
+ '<svg xmlns="http'+'://www.w3.org/2000/svg" width="'+W+'" height="'+H+'" viewBox="0 0 '+W+' '+H+'">',
2296
+ '<rect width="'+W+'" height="'+H+'" fill="#080d1a"/>'];
2297
+ // Edges
2298
+ for(const e of graphEdges){
2299
+ const ai=nodeMap.get(e.source),bi=nodeMap.get(e.target);
2300
+ if(ai==null||bi==null)continue;
2301
+ const a=graphNodes[ai],b=graphNodes[bi];
2302
+ const color=EDGE_CAT_COLORS[e.category]||'rgba(148,163,184,.2)';
2303
+ svgLines.push('<line x1="'+a.x.toFixed(1)+'" y1="'+a.y.toFixed(1)+'" x2="'+b.x.toFixed(1)+'" y2="'+b.y.toFixed(1)+'" stroke="'+color+'" stroke-width="0.8"/>');
2304
+ }
2305
+ // Nodes
2306
+ for(const n of graphNodes){
2307
+ const r=n.radius||6;
2308
+ const escaped=n.name.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
2309
+ svgLines.push('<circle cx="'+n.x.toFixed(1)+'" cy="'+n.y.toFixed(1)+'" r="'+r+'" fill="'+n.color+'"/>');
2310
+ svgLines.push('<text x="'+n.x.toFixed(1)+'" y="'+(n.y+r+11).toFixed(1)+'" text-anchor="middle" font-size="9" fill="#94a3b8" font-family="system-ui,sans-serif">'+escaped.slice(0,16)+'</text>');
2311
+ }
2312
+ svgLines.push('</svg>');
2313
+ const blob=new Blob([svgLines.join('\n')],{type:'image/svg+xml'});
2314
+ const a=document.createElement('a');
2315
+ a.href=URL.createObjectURL(blob);
2316
+ a.download='codebase-graph.svg';
2317
+ a.click();
2318
+ URL.revokeObjectURL(a.href);
2319
+ }
2320
+
2321
+ // ── Initial render ──
2322
+ requestAnimationFrame(()=>{
2323
+ resizeCanvas();
2324
+ applyFilters();
2325
+ if(!reducedMotion)animFrameId=requestAnimationFrame(drawGraph);
2326
+ });
2327
+
2328
+ // Resize observer (debounced)
2329
+ let resizeTimer=null;
2330
+ if(typeof ResizeObserver!=='undefined'){
2331
+ const ro=new ResizeObserver(()=>{
2332
+ clearTimeout(resizeTimer);
2333
+ resizeTimer=setTimeout(()=>resizeCanvas(),80);
2334
+ });
2335
+ ro.observe(graphArea);
2336
+ }
2337
+ }
2338
+
2339
+ // ── Initial renders ──
2340
+ renderGovern();
2341
+ renderMeasure();
2342
+ renderImprove();
2343
+ // Materialize the persisted persona on load. renderGovern() applies the persona
2344
+ // for the Business path, but early-returns the upsell for Free/Pro before reaching
2345
+ // it — so a persisted My Lens (Pro) selection would otherwise not render. Re-apply
2346
+ // here (idempotent for Business).
2347
+ if(typeof applyPersona==='function'){applyPersona(currentProfile.persona);}
2348
+ `;