@rune-kit/rune 2.18.1 → 2.21.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.
- package/README.md +31 -12
- package/compiler/__tests__/comprehension.test.js +916 -0
- package/compiler/__tests__/governance-collector.test.js +376 -0
- package/compiler/__tests__/setup.test.js +5 -0
- package/compiler/analytics.js +5 -0
- package/compiler/bin/rune.js +165 -1
- package/compiler/comprehension-client.js +2348 -0
- package/compiler/comprehension.js +1254 -0
- package/compiler/governance-collector.js +382 -0
- package/compiler/schemas/comprehension.schema.json +87 -0
- package/compiler/schemas/governance.schema.json +78 -0
- package/compiler/transforms/branding.js +1 -1
- package/hooks/context-watch/index.cjs +24 -13
- package/hooks/hooks.json +2 -2
- package/hooks/lib/context-key.cjs +37 -0
- package/hooks/metrics-collector/index.cjs +37 -8
- package/hooks/pre-tool-guard/index.cjs +44 -0
- package/hooks/session-start/index.cjs +4 -10
- package/package.json +1 -1
- package/skills/adversary/SKILL.md +36 -3
- package/skills/adversary/references/cross-model-escalation.md +85 -0
- package/skills/adversary/references/reasoning-modes.md +95 -0
- package/skills/autopsy/SKILL.md +41 -0
- package/skills/ba/SKILL.md +106 -27
- package/skills/ba/references/ears-format.md +91 -0
- package/skills/brainstorm/SKILL.md +32 -7
- package/skills/completion-gate/SKILL.md +21 -10
- package/skills/converge/SKILL.md +178 -0
- package/skills/converge/references/eval-fixtures.md +59 -0
- package/skills/cook/SKILL.md +41 -6
- package/skills/debug/SKILL.md +4 -2
- package/skills/deploy/SKILL.md +23 -2
- package/skills/deploy/references/observability.md +146 -0
- package/skills/design/SKILL.md +15 -1
- package/skills/graft/SKILL.md +24 -8
- package/skills/onboard/SKILL.md +45 -0
- package/skills/perf/SKILL.md +4 -1
- package/skills/plan/SKILL.md +70 -7
- package/skills/plan/references/boundary-artifacts.md +127 -0
- package/skills/plan/references/plan-templates.md +28 -9
- package/skills/plan/references/vertical-slice.md +8 -6
- package/skills/preflight/SKILL.md +9 -3
- package/skills/review/SKILL.md +4 -2
- package/skills/skill-router/SKILL.md +2 -0
- package/skills/test/SKILL.md +15 -8
- package/skills/verification/SKILL.md +39 -7
|
@@ -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,'&')
|
|
21
|
+
.replace(/</g,'<')
|
|
22
|
+
.replace(/>/g,'>')
|
|
23
|
+
.replace(/"/g,'"')
|
|
24
|
+
.replace(/'/g,''');
|
|
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='🗂'; // 🗂
|
|
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:'✅','text':'Compliance coverage map — obligations met, gap, unknown per pack'},
|
|
218
|
+
{icon:'🚫','text':'Gate ledger — sentinel, preflight, completion-gate fire + block counts'},
|
|
219
|
+
{icon:'🔍','text':'Decision provenance — output ↦ signal chain ↦ gate ↦ model ↦ cost'},
|
|
220
|
+
{icon:'📈','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">⚠ '+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='✓';pkStText='all met';}
|
|
502
|
+
else if(pkGap>0){pkStClass='fail';pkStIcon='✕';pkStText=pkGap+' gap'+(pkGap!==1?'s':'');}
|
|
503
|
+
else if(pkMet>0){pkStClass='warn';pkStIcon='△';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='✓';stText='met';}
|
|
533
|
+
else if(c.status==='gap'){stClass='fail';stIcon='✕';stText='gap';}
|
|
534
|
+
else if(c.status==='partial'){stClass='warn';stIcon='△';stText='partial';}
|
|
535
|
+
else{stClass='info';stIcon='‽';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 → gate → model → 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(' → '):'—';
|
|
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 • ';
|
|
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:'⚠',
|
|
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:'🛑',
|
|
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:'💥',
|
|
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:'🛈',
|
|
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:'🔰',
|
|
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:'△',
|
|
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>▶</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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
|
+
`;
|