helixevo 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1090 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useMemo, useState } from 'react'
4
+ import {
5
+ ReactFlow,
6
+ Background,
7
+ Controls,
8
+ type Node,
9
+ type Edge,
10
+ MarkerType,
11
+ ConnectionLineType,
12
+ } from '@xyflow/react'
13
+ import '@xyflow/react/dist/style.css'
14
+ import SkillFlowNode from '@/components/SkillFlowNode'
15
+
16
+ // ─── Types ──────────────────────────────────────────────────────
17
+
18
+ interface SkillNode {
19
+ id: string; name: string; score: number; generation: number
20
+ layer: string; status: string; tags: string[]; failureCount: number; lastEvolved: string
21
+ }
22
+
23
+ interface GraphEdge {
24
+ from: string; to: string; type: string; strength: number; detectedBy: string
25
+ }
26
+
27
+ interface EvolutionEntry {
28
+ id: string; timestamp: string; action: string; description: string
29
+ outcome: string; outcomeReason: string; task: number; align: number; sideEffect: number
30
+ }
31
+
32
+ interface ProjectData {
33
+ name: string; failures: any[]; skills: string[]
34
+ }
35
+
36
+ interface Props {
37
+ allNodes: SkillNode[]
38
+ generalized: SkillNode[]
39
+ evolved: SkillNode[]
40
+ original: SkillNode[]
41
+ edges: GraphEdge[]
42
+ evolutionBySkill: Record<string, EvolutionEntry[]>
43
+ skillContents: Record<string, string>
44
+ projects: ProjectData[]
45
+ stats: { nodes: number; edges: number; clusters: number; generalized: number; evolved: number; original: number }
46
+ }
47
+
48
+ type SubView = 'graph' | 'general' | 'projects' | 'coevolution'
49
+
50
+ const SUB_VIEWS: { key: SubView; label: string; desc: string }[] = [
51
+ { key: 'graph', label: 'Skill Graph', desc: 'Interactive network visualization' },
52
+ { key: 'general', label: 'General Skills', desc: 'Abstract and domain-level skills' },
53
+ { key: 'projects', label: 'Project Skills', desc: 'Per-project specialized skills' },
54
+ { key: 'coevolution', label: 'Co-Evolution', desc: 'Cross-layer knowledge flow' },
55
+ ]
56
+
57
+ const nodeTypes = { skillNode: SkillFlowNode }
58
+
59
+ // ─── Helpers ────────────────────────────────────────────────────
60
+
61
+ function scoreColor(s: number) {
62
+ return s >= 0.8 ? 'var(--green)' : s >= 0.6 ? 'var(--yellow)' : 'var(--red)'
63
+ }
64
+
65
+ const COLUMN_X = { generalized: 50, evolved: 400, original: 750, project: 1100 }
66
+ const EDGE_COLORS: Record<string, string> = {
67
+ inherits: '#818cf8', enhances: '#22c55e', conflicts: '#ef4444', 'co-evolves': '#f59e0b', depends: '#6366f1',
68
+ }
69
+ const EDGE_LABELS: Record<string, string> = {
70
+ inherits: 'inherits', enhances: 'enhances', conflicts: 'conflicts', 'co-evolves': 'co-evolves',
71
+ }
72
+
73
+ function actionBtnStyle(color?: string): React.CSSProperties {
74
+ return {
75
+ padding: '6px 12px', fontSize: 11, fontWeight: 600, borderRadius: 6, cursor: 'pointer',
76
+ border: `1px solid ${color ?? 'var(--border)'}`,
77
+ background: color ? `${color}10` : 'var(--bg-section)',
78
+ color: color ?? 'var(--text-dim)',
79
+ fontFamily: 'var(--font)',
80
+ transition: 'all 0.15s ease',
81
+ }
82
+ }
83
+
84
+ // ─── Main Component ─────────────────────────────────────────────
85
+
86
+ export default function NetworkClient({
87
+ allNodes, generalized, evolved, original, edges: graphEdges, evolutionBySkill, skillContents, projects, stats
88
+ }: Props) {
89
+ const [view, setView] = useState<SubView>('graph')
90
+ const [selectedSkill, setSelectedSkill] = useState<string | null>(null)
91
+ const [editing, setEditing] = useState(false)
92
+ const [editContent, setEditContent] = useState('')
93
+ const [creating, setCreating] = useState(false)
94
+ const [newSkillSlug, setNewSkillSlug] = useState('')
95
+ const [newSkillContent, setNewSkillContent] = useState('')
96
+ const [adaptation, setAdaptation] = useState<{ status: string; messages: { type: string; text: string }[]; suggestions: { type: string; description: string }[] } | null>(null)
97
+ const [actionLoading, setActionLoading] = useState(false)
98
+
99
+ // ─── Graph Sub-View Data ──────────────────────────────────────
100
+
101
+ const { flowNodes, flowEdges } = useMemo(() => {
102
+ const flowNodes: Node[] = []
103
+ const ROW_HEIGHT = 240
104
+
105
+ function getEdgesFor(id: string) {
106
+ return {
107
+ inheritsFrom: graphEdges.filter(e => e.type === 'inherits' && e.to === id).map(e => e.from),
108
+ children: graphEdges.filter(e => e.type === 'inherits' && e.from === id).map(e => e.to),
109
+ enhances: [...new Set(graphEdges.filter(e => e.type === 'enhances' && (e.from === id || e.to === id)).map(e => e.from === id ? e.to : e.from))],
110
+ conflicts: [...new Set(graphEdges.filter(e => e.type === 'conflicts' && (e.from === id || e.to === id)).map(e => e.from === id ? e.to : e.from))],
111
+ }
112
+ }
113
+
114
+ // Columns: Generalized → Evolved → Original → Projects
115
+ const addNodes = (nodes: SkillNode[], x: number, category: string) => {
116
+ nodes.forEach((n, i) => {
117
+ const ne = getEdgesFor(n.id)
118
+ const evo = evolutionBySkill[n.id] ?? []
119
+ flowNodes.push({
120
+ id: n.id, type: 'skillNode',
121
+ position: { x, y: 80 + i * ROW_HEIGHT },
122
+ data: {
123
+ name: n.name, score: n.score, generation: n.generation,
124
+ category, layer: n.layer,
125
+ description: (skillContents[n.id] ?? '').slice(0, 100),
126
+ inheritsFrom: ne.inheritsFrom, children: ne.children,
127
+ enhances: ne.enhances, conflicts: ne.conflicts,
128
+ evolutionSummary: evo.filter(e => e.outcome === 'accepted').map(e => e.description),
129
+ tags: n.tags.length > 0 ? n.tags : [category],
130
+ },
131
+ })
132
+ })
133
+ }
134
+
135
+ addNodes(generalized, COLUMN_X.generalized, 'generalized')
136
+ addNodes(evolved, COLUMN_X.evolved, 'evolved')
137
+ addNodes(original, COLUMN_X.original, 'original')
138
+
139
+ // Project nodes
140
+ let projY = 80
141
+ projects.forEach(proj => {
142
+ flowNodes.push({
143
+ id: `project-${proj.name}`, type: 'skillNode',
144
+ position: { x: COLUMN_X.project, y: projY },
145
+ data: {
146
+ name: proj.name,
147
+ score: 1 - (proj.failures.filter((f: any) => !f.resolved).length / Math.max(1, proj.failures.length)),
148
+ generation: 0, category: 'project', layer: 'project',
149
+ description: `${proj.failures.length} failures, ${proj.skills.length} skills`,
150
+ inheritsFrom: [], children: [],
151
+ enhances: proj.skills, conflicts: [],
152
+ evolutionSummary: proj.failures.slice(0, 2).map((f: any) => `${f.userRequest.slice(0, 50)} → ${f.correction.slice(0, 30)}`),
153
+ tags: proj.skills.slice(0, 3),
154
+ },
155
+ })
156
+ projY += ROW_HEIGHT
157
+ })
158
+
159
+ // Edges
160
+ const flowEdges: Edge[] = []
161
+ const seenEdges = new Set<string>()
162
+
163
+ for (const e of graphEdges) {
164
+ const key = `${e.from}-${e.to}-${e.type}`
165
+ if (seenEdges.has(key)) continue
166
+ seenEdges.add(key)
167
+ if (!flowNodes.some(n => n.id === e.from) || !flowNodes.some(n => n.id === e.to)) continue
168
+
169
+ const color = EDGE_COLORS[e.type] ?? '#9ca3af'
170
+ flowEdges.push({
171
+ id: key, source: e.from, target: e.to,
172
+ sourceHandle: 'right', targetHandle: 'left',
173
+ type: 'smoothstep',
174
+ animated: e.type === 'co-evolves',
175
+ label: EDGE_LABELS[e.type],
176
+ labelStyle: { fontSize: 10, fontWeight: 500, fill: color },
177
+ labelBgStyle: { fill: '#fff', fillOpacity: 0.9 },
178
+ labelBgPadding: [4, 6] as [number, number],
179
+ labelBgBorderRadius: 4,
180
+ style: { stroke: color, strokeWidth: e.type === 'inherits' ? 2 : 1.5, strokeDasharray: e.type === 'conflicts' ? '6 4' : undefined },
181
+ markerEnd: { type: MarkerType.ArrowClosed, color, width: 12, height: 12 },
182
+ })
183
+ }
184
+
185
+ // Project ↔ skill edges
186
+ for (const proj of projects) {
187
+ for (const skillSlug of proj.skills) {
188
+ if (flowNodes.some(n => n.id === skillSlug)) {
189
+ flowEdges.push({
190
+ id: `project-${proj.name}-${skillSlug}`,
191
+ source: skillSlug, target: `project-${proj.name}`,
192
+ sourceHandle: 'right', targetHandle: 'left',
193
+ type: 'smoothstep',
194
+ label: 'used in',
195
+ labelStyle: { fontSize: 9, fill: '#d97706' },
196
+ labelBgStyle: { fill: '#fff', fillOpacity: 0.9 },
197
+ labelBgPadding: [3, 5] as [number, number],
198
+ labelBgBorderRadius: 3,
199
+ style: { stroke: '#fbbf24', strokeWidth: 1, strokeDasharray: '4 3' },
200
+ markerEnd: { type: MarkerType.ArrowClosed, color: '#fbbf24', width: 10, height: 10 },
201
+ })
202
+ }
203
+ }
204
+ }
205
+
206
+ return { flowNodes, flowEdges }
207
+ }, [generalized, evolved, original, graphEdges, evolutionBySkill, skillContents, projects])
208
+
209
+ // ─── Detail Panel ─────────────────────────────────────────────
210
+
211
+ const selectedNode = allNodes.find(n => n.id === selectedSkill)
212
+ const selectedEvo = selectedSkill ? (evolutionBySkill[selectedSkill] ?? []) : []
213
+ const selectedContent = selectedSkill ? (skillContents[selectedSkill] ?? '') : ''
214
+ const selectedEdges = selectedSkill ? {
215
+ inheritsFrom: graphEdges.filter(e => e.type === 'inherits' && e.to === selectedSkill),
216
+ children: graphEdges.filter(e => e.type === 'inherits' && e.from === selectedSkill),
217
+ enhances: graphEdges.filter(e => e.type === 'enhances' && (e.from === selectedSkill || e.to === selectedSkill)),
218
+ conflicts: graphEdges.filter(e => e.type === 'conflicts' && (e.from === selectedSkill || e.to === selectedSkill)),
219
+ } : null
220
+
221
+ // ─── Helpers ──────────────────────────────────────────────────
222
+
223
+ const handleCreateSkill = async () => {
224
+ if (!newSkillSlug || !newSkillContent) return
225
+ setActionLoading(true)
226
+ const res = await fetch(`/api/skills`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
227
+ body: JSON.stringify({ slug: newSkillSlug, content: newSkillContent }) })
228
+ const data = await res.json()
229
+ setAdaptation(data.adaptation)
230
+ setActionLoading(false)
231
+ if (data.success) {
232
+ setCreating(false)
233
+ setNewSkillSlug('')
234
+ setNewSkillContent('')
235
+ setTimeout(() => window.location.reload(), 1000)
236
+ }
237
+ }
238
+
239
+ // ─── Render ───────────────────────────────────────────────────
240
+
241
+ return (
242
+ <div>
243
+ {/* Page Header */}
244
+ <div className="page-header">
245
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
246
+ <div>
247
+ <h1 className="page-title">Skill Network</h1>
248
+ <p className="page-desc">
249
+ {stats.nodes} skills · {stats.edges} relationships · {stats.clusters} clusters
250
+ {stats.generalized > 0 && ` · ${stats.generalized} generalized`}
251
+ </p>
252
+ </div>
253
+ <button onClick={() => setCreating(!creating)} style={{
254
+ background: creating ? 'var(--bg-section)' : 'linear-gradient(135deg, var(--purple), #4f46e5)',
255
+ color: creating ? 'var(--text-dim)' : '#fff',
256
+ border: 'none', borderRadius: 8, padding: '8px 16px', fontSize: 13, fontWeight: 600,
257
+ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6, fontFamily: 'var(--font)',
258
+ }}>{creating ? '✕ Cancel' : '+ New Skill'}</button>
259
+ </div>
260
+ </div>
261
+
262
+ {/* Create Skill Panel */}
263
+ {creating && (
264
+ <div className="card" style={{ marginBottom: 20 }}>
265
+ <div className="card-body">
266
+ <div className="card-header-label">Create New Skill</div>
267
+ <div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
268
+ <div style={{ flex: 1 }}>
269
+ <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-dim)', display: 'block', marginBottom: 4 }}>Skill Slug</label>
270
+ <input value={newSkillSlug} onChange={e => setNewSkillSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'))}
271
+ placeholder="my-new-skill" style={{
272
+ width: '100%', padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8,
273
+ fontSize: 13, fontFamily: 'var(--font-mono)', background: 'var(--bg-input)', color: 'var(--text)',
274
+ }} />
275
+ </div>
276
+ </div>
277
+ <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-dim)', display: 'block', marginBottom: 4 }}>SKILL.md Content</label>
278
+ <textarea value={newSkillContent} onChange={e => setNewSkillContent(e.target.value)}
279
+ placeholder={`---\nname: My New Skill\ndescription: What this skill does\nlayer: domain\ntags: [tag1, tag2]\n---\n\n## Rules\n\n- Rule 1\n- Rule 2`}
280
+ style={{
281
+ width: '100%', height: 200, fontFamily: 'var(--font-mono)', fontSize: 11.5,
282
+ background: '#1e1e2e', color: '#cdd6f4', border: '1px solid var(--border)',
283
+ borderRadius: 8, padding: 12, resize: 'vertical', lineHeight: 1.6,
284
+ }} />
285
+ <div style={{ display: 'flex', gap: 8, marginTop: 12, justifyContent: 'flex-end' }}>
286
+ <button onClick={handleCreateSkill} disabled={!newSkillSlug || !newSkillContent || actionLoading}
287
+ style={{
288
+ background: newSkillSlug && newSkillContent ? 'linear-gradient(135deg, var(--green), #059669)' : 'var(--bg-section)',
289
+ color: newSkillSlug && newSkillContent ? '#fff' : 'var(--text-muted)',
290
+ border: 'none', borderRadius: 8, padding: '8px 20px', fontSize: 13, fontWeight: 600,
291
+ cursor: newSkillSlug && newSkillContent ? 'pointer' : 'default', fontFamily: 'var(--font)',
292
+ }}>{actionLoading ? 'Creating...' : '✓ Create Skill'}</button>
293
+ </div>
294
+
295
+ {/* Show adaptation feedback for create */}
296
+ {adaptation && creating && (
297
+ <div style={{ marginTop: 12 }}>
298
+ {adaptation.messages.map((m, i) => (
299
+ <div key={i} style={{ fontSize: 12, padding: '6px 10px', marginBottom: 3, borderRadius: 6,
300
+ background: m.type === 'warning' ? 'var(--yellow-light)' : 'var(--bg-section)',
301
+ color: m.type === 'warning' ? 'var(--yellow)' : 'var(--text-dim)',
302
+ }}>{m.type === 'warning' ? '⚠ ' : 'ℹ '}{m.text}</div>
303
+ ))}
304
+ </div>
305
+ )}
306
+ </div>
307
+ </div>
308
+ )}
309
+
310
+ {/* Sub-View Tabs */}
311
+ <div className="tab-bar">
312
+ {SUB_VIEWS.map(sv => (
313
+ <button
314
+ key={sv.key}
315
+ className={`tab-item ${view === sv.key ? 'active' : ''}`}
316
+ onClick={() => { setView(sv.key); setSelectedSkill(null) }}
317
+ >
318
+ {sv.label}
319
+ </button>
320
+ ))}
321
+ </div>
322
+
323
+ {/* Content area with optional detail panel */}
324
+ <div style={{ display: 'flex', gap: 0 }}>
325
+ {/* Main content */}
326
+ <div style={{ flex: 1, minWidth: 0 }}>
327
+ {view === 'graph' && <GraphView flowNodes={flowNodes} flowEdges={flowEdges} stats={stats} />}
328
+ {view === 'general' && (
329
+ <GeneralView
330
+ generalized={generalized} evolved={evolved} original={original}
331
+ edges={graphEdges} evolutionBySkill={evolutionBySkill} skillContents={skillContents}
332
+ onSelect={setSelectedSkill} selected={selectedSkill}
333
+ />
334
+ )}
335
+ {view === 'projects' && (
336
+ <ProjectsView projects={projects} allNodes={allNodes} onSelect={setSelectedSkill} selected={selectedSkill} />
337
+ )}
338
+ {view === 'coevolution' && (
339
+ <CoEvolutionView
340
+ generalized={generalized} evolved={evolved} original={original}
341
+ edges={graphEdges} evolutionBySkill={evolutionBySkill} projects={projects}
342
+ />
343
+ )}
344
+ </div>
345
+
346
+ {/* Detail Panel */}
347
+ {selectedSkill && selectedNode && (
348
+ <div className="detail-panel">
349
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
350
+ <div>
351
+ <div style={{ fontSize: 17, fontWeight: 700, marginBottom: 4 }}>{selectedNode.name}</div>
352
+ <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
353
+ <span className="badge badge-gray">{selectedNode.layer}</span>
354
+ {selectedNode.generation > 0 && <span className="badge badge-green">gen {selectedNode.generation}</span>}
355
+ {selectedNode.tags.map(t => <span key={t} className="badge badge-gray">{t}</span>)}
356
+ </div>
357
+ </div>
358
+ <button onClick={() => setSelectedSkill(null)} style={{
359
+ background: 'var(--bg-section)', border: 'none', borderRadius: 6, width: 28, height: 28,
360
+ cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
361
+ color: 'var(--text-dim)', fontSize: 14,
362
+ }}>✕</button>
363
+ </div>
364
+
365
+ {/* Score */}
366
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 20, padding: '12px 14px', background: 'var(--bg-section)', borderRadius: 10 }}>
367
+ <div style={{ fontSize: 32, fontWeight: 800, color: scoreColor(selectedNode.score), lineHeight: 1 }}>
368
+ {(selectedNode.score * 100).toFixed(0)}
369
+ </div>
370
+ <div style={{ flex: 1 }}>
371
+ <div style={{ fontSize: 11, color: 'var(--text-dim)', marginBottom: 4 }}>Quality Score</div>
372
+ <div className="score-track" style={{ height: 6 }}>
373
+ <div className="score-fill" style={{
374
+ width: `${selectedNode.score * 100}%`,
375
+ background: `linear-gradient(90deg, var(--blue), ${scoreColor(selectedNode.score)})`,
376
+ }} />
377
+ </div>
378
+ </div>
379
+ </div>
380
+
381
+ {/* Connections */}
382
+ {selectedEdges && (selectedEdges.inheritsFrom.length > 0 || selectedEdges.children.length > 0 || selectedEdges.enhances.length > 0 || selectedEdges.conflicts.length > 0) && (
383
+ <div style={{ marginBottom: 20 }}>
384
+ <div className="section-label">Connections</div>
385
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
386
+ {selectedEdges.inheritsFrom.length > 0 && (
387
+ <div style={{ fontSize: 12 }}>
388
+ <span style={{ color: 'var(--text-dim)' }}>Inherits from: </span>
389
+ {selectedEdges.inheritsFrom.map(e => <span key={e.from} className="badge badge-purple" style={{ marginRight: 3 }}>{e.from}</span>)}
390
+ </div>
391
+ )}
392
+ {selectedEdges.children.length > 0 && (
393
+ <div style={{ fontSize: 12 }}>
394
+ <span style={{ color: 'var(--text-dim)' }}>Children: </span>
395
+ {selectedEdges.children.map(e => <span key={e.to} className="badge badge-blue" style={{ marginRight: 3 }}>{e.to}</span>)}
396
+ </div>
397
+ )}
398
+ {selectedEdges.enhances.length > 0 && (
399
+ <div style={{ fontSize: 12 }}>
400
+ <span style={{ color: 'var(--text-dim)' }}>Enhances: </span>
401
+ {[...new Set(selectedEdges.enhances.map(e => e.from === selectedSkill ? e.to : e.from))].map(id =>
402
+ <span key={id} className="badge badge-green" style={{ marginRight: 3 }}>{id}</span>
403
+ )}
404
+ </div>
405
+ )}
406
+ {selectedEdges.conflicts.length > 0 && (
407
+ <div style={{ fontSize: 12 }}>
408
+ <span style={{ color: 'var(--text-dim)' }}>Conflicts: </span>
409
+ {[...new Set(selectedEdges.conflicts.map(e => e.from === selectedSkill ? e.to : e.from))].map(id =>
410
+ <span key={id} className="badge badge-red" style={{ marginRight: 3 }}>{id}</span>
411
+ )}
412
+ </div>
413
+ )}
414
+ </div>
415
+ </div>
416
+ )}
417
+
418
+ {/* Evolution History */}
419
+ {selectedEvo.length > 0 && (
420
+ <div style={{ marginBottom: 20 }}>
421
+ <div className="section-label">Evolution History</div>
422
+ {selectedEvo.map((e, i) => (
423
+ <div key={i} style={{
424
+ padding: '8px 12px', marginBottom: 6, borderRadius: 8,
425
+ background: 'var(--bg-section)',
426
+ borderLeft: `3px solid ${e.outcome === 'accepted' ? 'var(--green)' : 'var(--red)'}`,
427
+ }}>
428
+ <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, marginBottom: 3 }}>
429
+ <span className={`badge ${e.outcome === 'accepted' ? 'badge-green' : 'badge-red'}`}>{e.outcome}</span>
430
+ <span style={{ color: 'var(--text-muted)' }}>{new Date(e.timestamp).toLocaleDateString()}</span>
431
+ </div>
432
+ <div style={{ fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.5, marginBottom: 4 }}>{e.description.slice(0, 150)}</div>
433
+ <div style={{ display: 'flex', gap: 8, fontSize: 10 }}>
434
+ <span className="score-pill">T:{e.task}</span>
435
+ <span className="score-pill">A:{e.align}</span>
436
+ <span className="score-pill">S:{e.sideEffect}</span>
437
+ </div>
438
+ </div>
439
+ ))}
440
+ </div>
441
+ )}
442
+
443
+ {/* ─── Management Actions ─── */}
444
+ <div style={{ marginBottom: 20 }}>
445
+ <div className="section-label">Actions</div>
446
+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
447
+ <button onClick={() => { setEditing(!editing); setEditContent(skillContents[selectedSkill!] ?? ''); setAdaptation(null) }}
448
+ style={actionBtnStyle(editing ? 'var(--purple)' : undefined)}>
449
+ {editing ? '✕ Cancel' : '✎ Edit'}
450
+ </button>
451
+ <button onClick={async () => {
452
+ if (!selectedNode) return
453
+ const layers = ['project', 'domain', 'system'] as const
454
+ const currentIdx = layers.indexOf(selectedNode.layer as typeof layers[number])
455
+ const nextLayer = layers[Math.min(currentIdx + 1, 2)]
456
+ if (nextLayer === selectedNode.layer) return
457
+ setActionLoading(true)
458
+ const res = await fetch(`/api/skills`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
459
+ body: JSON.stringify({ slug: selectedSkill, layer: nextLayer }) })
460
+ const data = await res.json()
461
+ setAdaptation(data.adaptation)
462
+ setActionLoading(false)
463
+ }} style={actionBtnStyle('var(--blue)')}>
464
+ ↑ Promote
465
+ </button>
466
+ <button onClick={async () => {
467
+ if (!selectedNode) return
468
+ const layers = ['project', 'domain', 'system'] as const
469
+ const currentIdx = layers.indexOf(selectedNode.layer as typeof layers[number])
470
+ const nextLayer = layers[Math.max(currentIdx - 1, 0)]
471
+ if (nextLayer === selectedNode.layer) return
472
+ setActionLoading(true)
473
+ const res = await fetch(`/api/skills`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
474
+ body: JSON.stringify({ slug: selectedSkill, layer: nextLayer }) })
475
+ const data = await res.json()
476
+ setAdaptation(data.adaptation)
477
+ setActionLoading(false)
478
+ }} style={actionBtnStyle('var(--yellow)')}>
479
+ ↓ Demote
480
+ </button>
481
+ <button onClick={async () => {
482
+ if (!confirm(`Delete "${selectedNode?.name}"? This cannot be undone.`)) return
483
+ setActionLoading(true)
484
+ const res = await fetch(`/api/skills?slug=${selectedSkill}`, { method: 'DELETE' })
485
+ const data = await res.json()
486
+ setAdaptation(data.adaptation)
487
+ setActionLoading(false)
488
+ if (data.success) setTimeout(() => window.location.reload(), 1500)
489
+ }} style={actionBtnStyle('var(--red)')}>
490
+ ✕ Delete
491
+ </button>
492
+ </div>
493
+ </div>
494
+
495
+ {/* ─── Edit Mode ─── */}
496
+ {editing && (
497
+ <div style={{ marginBottom: 20 }}>
498
+ <div className="section-label">Edit Skill Content</div>
499
+ <textarea
500
+ value={editContent}
501
+ onChange={e => setEditContent(e.target.value)}
502
+ style={{
503
+ width: '100%', height: 250, fontFamily: 'var(--font-mono)', fontSize: 11,
504
+ background: '#1e1e2e', color: '#cdd6f4', border: '1px solid var(--border)',
505
+ borderRadius: 8, padding: 12, resize: 'vertical', lineHeight: 1.6,
506
+ }}
507
+ />
508
+ <button onClick={async () => {
509
+ setActionLoading(true)
510
+ const res = await fetch(`/api/skills`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
511
+ body: JSON.stringify({ slug: selectedSkill, content: `---\n${Object.entries(selectedNode ? { name: selectedNode.name, description: '', layer: selectedNode.layer } : {}).map(([k,v]) => `${k}: ${v}`).join('\n')}\n---\n\n${editContent}` }) })
512
+ const data = await res.json()
513
+ setAdaptation(data.adaptation)
514
+ setEditing(false)
515
+ setActionLoading(false)
516
+ setTimeout(() => window.location.reload(), 1000)
517
+ }} disabled={actionLoading}
518
+ style={{ ...actionBtnStyle('var(--green)'), width: '100%', marginTop: 8 }}>
519
+ {actionLoading ? 'Saving...' : '✓ Save Changes'}
520
+ </button>
521
+ </div>
522
+ )}
523
+
524
+ {/* ─── Network Adaptation Feedback ─── */}
525
+ {adaptation && (
526
+ <div style={{ marginBottom: 20 }}>
527
+ <div className="section-label">
528
+ Network Adaptation
529
+ <span style={{
530
+ marginLeft: 8, fontSize: 10, fontWeight: 600, padding: '2px 8px', borderRadius: 6,
531
+ background: adaptation.status === 'ok' ? 'var(--green-light)' : adaptation.status === 'warning' ? 'var(--yellow-light)' : 'var(--red-light)',
532
+ color: adaptation.status === 'ok' ? 'var(--green)' : adaptation.status === 'warning' ? 'var(--yellow)' : 'var(--red)',
533
+ textTransform: 'none', letterSpacing: 0,
534
+ }}>{adaptation.status}</span>
535
+ </div>
536
+ {adaptation.messages.map((m, i) => (
537
+ <div key={i} style={{
538
+ padding: '8px 12px', marginBottom: 4, borderRadius: 6, fontSize: 12, lineHeight: 1.5,
539
+ background: m.type === 'warning' ? 'var(--yellow-light)' : m.type === 'action' ? 'var(--red-light)' : 'var(--bg-section)',
540
+ color: m.type === 'warning' ? 'var(--yellow)' : m.type === 'action' ? 'var(--red)' : 'var(--text-secondary)',
541
+ border: `1px solid ${m.type === 'warning' ? 'var(--yellow-border)' : m.type === 'action' ? 'var(--red-border)' : 'var(--border)'}`,
542
+ }}>{m.type === 'warning' ? '⚠ ' : m.type === 'action' ? '⚡ ' : 'ℹ '}{m.text}</div>
543
+ ))}
544
+ {adaptation.suggestions.length > 0 && (
545
+ <div style={{ marginTop: 8 }}>
546
+ <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 4 }}>SUGGESTIONS</div>
547
+ {adaptation.suggestions.map((s, i) => (
548
+ <div key={i} style={{
549
+ padding: '6px 10px', fontSize: 11, color: 'var(--text-dim)', lineHeight: 1.5,
550
+ borderLeft: '2px solid var(--purple)', marginBottom: 4, paddingLeft: 10,
551
+ }}>
552
+ <span className="badge badge-purple" style={{ marginRight: 6 }}>{s.type}</span>
553
+ {s.description}
554
+ </div>
555
+ ))}
556
+ </div>
557
+ )}
558
+ </div>
559
+ )}
560
+
561
+ {/* Skill Content (read-only when not editing) */}
562
+ {selectedContent && !editing && (
563
+ <div>
564
+ <div className="section-label">Skill Content</div>
565
+ <pre style={{
566
+ background: 'var(--bg-section)', borderRadius: 8, padding: 12,
567
+ whiteSpace: 'pre-wrap', wordBreak: 'break-word',
568
+ color: 'var(--text-secondary)', lineHeight: 1.6, maxHeight: 300, overflow: 'auto',
569
+ fontSize: 11, border: '1px solid var(--border)',
570
+ }}>
571
+ {selectedContent.slice(0, 1500)}
572
+ </pre>
573
+ </div>
574
+ )}
575
+ </div>
576
+ )}
577
+ </div>
578
+ </div>
579
+ )
580
+ }
581
+
582
+ // ═══════════════════════════════════════════════════════════════════
583
+ // SUB-VIEW: Graph
584
+ // ═══════════════════════════════════════════════════════════════════
585
+
586
+ function GraphView({ flowNodes, flowEdges, stats }: { flowNodes: Node[]; flowEdges: Edge[]; stats: Props['stats'] }) {
587
+ return (
588
+ <div style={{ height: 'calc(100vh - 200px)', width: '100%', borderRadius: 12, overflow: 'hidden', border: '1px solid var(--border)', background: '#fafafa' }}>
589
+ {/* Column Headers */}
590
+ <div style={{
591
+ position: 'absolute', top: 8, left: 0, right: 0, zIndex: 10,
592
+ display: 'flex', justifyContent: 'space-around', padding: '0 60px',
593
+ pointerEvents: 'none',
594
+ }}>
595
+ {[
596
+ { label: 'GENERALIZED', color: '#7c3aed', count: stats.generalized },
597
+ { label: 'EVOLVED', color: '#16a34a', count: stats.evolved },
598
+ { label: 'ORIGINAL', color: '#6b7280', count: stats.original },
599
+ { label: 'PROJECTS', color: '#b45309', count: 0 },
600
+ ].map(col => (
601
+ <div key={col.label} style={{
602
+ background: '#fff', border: '1px solid #e5e7eb', borderRadius: 8,
603
+ padding: '5px 14px', textAlign: 'center',
604
+ boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
605
+ }}>
606
+ <div style={{ fontSize: 9, fontWeight: 700, letterSpacing: 1.5, color: col.color }}>{col.label}</div>
607
+ {col.count > 0 && <div style={{ fontSize: 9, color: '#9ca3af', marginTop: 1 }}>{col.count} skills</div>}
608
+ </div>
609
+ ))}
610
+ </div>
611
+
612
+ <ReactFlow
613
+ nodes={flowNodes}
614
+ edges={flowEdges}
615
+ nodeTypes={nodeTypes}
616
+ connectionLineType={ConnectionLineType.SmoothStep}
617
+ fitView
618
+ fitViewOptions={{ padding: 0.2 }}
619
+ proOptions={{ hideAttribution: true }}
620
+ style={{ background: '#fafafa' }}
621
+ minZoom={0.3}
622
+ maxZoom={1.5}
623
+ >
624
+ <Background color="#e5e7eb" gap={20} size={1} />
625
+ <Controls position="bottom-left" />
626
+ </ReactFlow>
627
+
628
+ {/* Legend */}
629
+ <div style={{
630
+ position: 'absolute', bottom: 12, right: 12, zIndex: 10,
631
+ background: '#fff', border: '1px solid #e5e7eb', borderRadius: 8,
632
+ padding: '8px 14px', display: 'flex', gap: 14, fontSize: 10, color: '#6b7280',
633
+ boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
634
+ }}>
635
+ <span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#818cf8', marginRight: 4, verticalAlign: 'middle' }} /> inherits</span>
636
+ <span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#22c55e', marginRight: 4, verticalAlign: 'middle' }} /> enhances</span>
637
+ <span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#ef4444', marginRight: 4, verticalAlign: 'middle', borderTop: '1px dashed #ef4444' }} /> conflicts</span>
638
+ <span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#fbbf24', marginRight: 4, verticalAlign: 'middle' }} /> project</span>
639
+ <span style={{ color: '#b0b5c8' }}>Scroll to zoom · Drag to pan</span>
640
+ </div>
641
+ </div>
642
+ )
643
+ }
644
+
645
+ // ═══════════════════════════════════════════════════════════════════
646
+ // SUB-VIEW: General Skills
647
+ // ═══════════════════════════════════════════════════════════════════
648
+
649
+ function GeneralView({
650
+ generalized, evolved, original, edges, evolutionBySkill, skillContents, onSelect, selected,
651
+ }: {
652
+ generalized: SkillNode[]; evolved: SkillNode[]; original: SkillNode[]
653
+ edges: GraphEdge[]; evolutionBySkill: Record<string, EvolutionEntry[]>
654
+ skillContents: Record<string, string>; onSelect: (id: string) => void; selected: string | null
655
+ }) {
656
+ function SkillCard({ node, category }: { node: SkillNode; category: string }) {
657
+ const nodeEdges = {
658
+ inheritsFrom: edges.filter(e => e.type === 'inherits' && e.to === node.id),
659
+ children: edges.filter(e => e.type === 'inherits' && e.from === node.id),
660
+ enhances: edges.filter(e => e.type === 'enhances' && (e.from === node.id || e.to === node.id)),
661
+ }
662
+ const evo = evolutionBySkill[node.id] ?? []
663
+ const borderColor = category === 'generalized' ? 'var(--purple)' : category === 'evolved' ? 'var(--green)' : 'var(--border)'
664
+ const isSelected = selected === node.id
665
+
666
+ return (
667
+ <div
668
+ className={`card ${isSelected ? 'selected' : ''}`}
669
+ onClick={() => onSelect(node.id)}
670
+ style={{ borderLeft: `3px solid ${borderColor}`, cursor: 'pointer', overflow: 'hidden' }}
671
+ >
672
+ <div className="card-body" style={{ padding: 16 }}>
673
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 10 }}>
674
+ <div>
675
+ <div style={{ fontSize: 14, fontWeight: 700, marginBottom: 4 }}>{node.name}</div>
676
+ <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
677
+ <span className={`badge ${category === 'generalized' ? 'badge-purple' : category === 'evolved' ? 'badge-green' : 'badge-gray'}`}>
678
+ {category}
679
+ </span>
680
+ {node.generation > 0 && <span className="badge badge-green">gen {node.generation}</span>}
681
+ <span className="badge badge-gray">{node.layer}</span>
682
+ </div>
683
+ </div>
684
+ <div style={{ textAlign: 'right' }}>
685
+ <div style={{ fontSize: 24, fontWeight: 800, color: scoreColor(node.score), lineHeight: 1 }}>
686
+ {(node.score * 100).toFixed(0)}
687
+ </div>
688
+ <div style={{ fontSize: 10, color: 'var(--text-muted)' }}>score</div>
689
+ </div>
690
+ </div>
691
+
692
+ <div className="score-track" style={{ height: 4, marginBottom: 12 }}>
693
+ <div className="score-fill" style={{
694
+ width: `${node.score * 100}%`,
695
+ background: `linear-gradient(90deg, var(--blue), ${scoreColor(node.score)})`,
696
+ }} />
697
+ </div>
698
+
699
+ {/* Connections summary */}
700
+ {(nodeEdges.inheritsFrom.length > 0 || nodeEdges.children.length > 0 || nodeEdges.enhances.length > 0) && (
701
+ <div style={{ display: 'flex', gap: 8, fontSize: 11, marginBottom: 10, flexWrap: 'wrap' }}>
702
+ {nodeEdges.inheritsFrom.length > 0 && (
703
+ <span style={{ color: 'var(--text-dim)' }}>↑ {nodeEdges.inheritsFrom.length} parent{nodeEdges.inheritsFrom.length > 1 ? 's' : ''}</span>
704
+ )}
705
+ {nodeEdges.children.length > 0 && (
706
+ <span style={{ color: 'var(--text-dim)' }}>↓ {nodeEdges.children.length} child{nodeEdges.children.length > 1 ? 'ren' : ''}</span>
707
+ )}
708
+ {nodeEdges.enhances.length > 0 && (
709
+ <span style={{ color: 'var(--text-dim)' }}>↔ {nodeEdges.enhances.length} enhance</span>
710
+ )}
711
+ </div>
712
+ )}
713
+
714
+ {/* Evolution badges */}
715
+ {evo.length > 0 && (
716
+ <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
717
+ {evo.slice(0, 3).map((e, i) => (
718
+ <span key={i} className={`badge ${e.outcome === 'accepted' ? 'badge-green' : 'badge-red'}`} style={{ fontSize: 9 }}>
719
+ {e.action} {e.outcome === 'accepted' ? '✓' : '✕'}
720
+ </span>
721
+ ))}
722
+ {evo.length > 3 && <span className="badge badge-gray" style={{ fontSize: 9 }}>+{evo.length - 3}</span>}
723
+ </div>
724
+ )}
725
+ </div>
726
+ </div>
727
+ )
728
+ }
729
+
730
+ return (
731
+ <div>
732
+ {/* Generalized Layer */}
733
+ {generalized.length > 0 && (
734
+ <div className="layer-section layer-generalized">
735
+ <div className="section-label" style={{ color: 'var(--purple)' }}>
736
+ Generalized Skills
737
+ <span style={{ fontWeight: 400, color: 'var(--text-dim)', fontSize: 11, textTransform: 'none', letterSpacing: 0 }}>
738
+ — abstracted from cross-project patterns
739
+ </span>
740
+ </div>
741
+ <div className="grid-auto">
742
+ {generalized.map(n => <SkillCard key={n.id} node={n} category="generalized" />)}
743
+ </div>
744
+ <div className="flow-arrow">▼ specializes into domain skills ▼</div>
745
+ </div>
746
+ )}
747
+
748
+ {/* Evolved Layer */}
749
+ {evolved.length > 0 && (
750
+ <div className="layer-section layer-evolved">
751
+ <div className="section-label" style={{ color: 'var(--green)' }}>
752
+ Evolved Skills
753
+ <span style={{ fontWeight: 400, color: 'var(--text-dim)', fontSize: 11, textTransform: 'none', letterSpacing: 0 }}>
754
+ — improved through failure-driven evolution
755
+ </span>
756
+ </div>
757
+ <div className="grid-auto">
758
+ {evolved.map(n => <SkillCard key={n.id} node={n} category="evolved" />)}
759
+ </div>
760
+ </div>
761
+ )}
762
+
763
+ {/* Original Layer */}
764
+ {original.length > 0 && (
765
+ <div className="layer-section layer-original">
766
+ <div className="section-label" style={{ color: 'var(--text-dim)' }}>
767
+ Original Skills
768
+ <span style={{ fontWeight: 400, fontSize: 11, textTransform: 'none', letterSpacing: 0 }}>
769
+ — not yet evolved
770
+ </span>
771
+ </div>
772
+ <div className="grid-auto">
773
+ {original.map(n => <SkillCard key={n.id} node={n} category="original" />)}
774
+ </div>
775
+ {generalized.length > 0 && <div className="flow-arrow">▲ common patterns generalize upward ▲</div>}
776
+ </div>
777
+ )}
778
+ </div>
779
+ )
780
+ }
781
+
782
+ // ═══════════════════════════════════════════════════════════════════
783
+ // SUB-VIEW: Project Skills
784
+ // ═══════════════════════════════════════════════════════════════════
785
+
786
+ function ProjectsView({
787
+ projects, allNodes, onSelect, selected,
788
+ }: {
789
+ projects: ProjectData[]; allNodes: SkillNode[]
790
+ onSelect: (id: string) => void; selected: string | null
791
+ }) {
792
+ if (projects.length === 0) {
793
+ return (
794
+ <div className="card">
795
+ <div className="empty-state">
796
+ <div className="empty-state-icon">
797
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
798
+ <path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
799
+ </svg>
800
+ </div>
801
+ <div className="empty-state-title">No projects detected</div>
802
+ <div className="empty-state-desc">
803
+ Projects are auto-detected from captured failures. Use <code>helixevo capture --project name</code> to tag failures.
804
+ </div>
805
+ </div>
806
+ </div>
807
+ )
808
+ }
809
+
810
+ return (
811
+ <div style={{ display: 'grid', gap: 16 }}>
812
+ {projects.map(project => {
813
+ const unresolved = project.failures.filter((f: any) => !f.resolved)
814
+ const resolvedPct = project.failures.length > 0 ? ((project.failures.length - unresolved.length) / project.failures.length * 100).toFixed(0) : '0'
815
+
816
+ return (
817
+ <div key={project.name} className="card" style={{ overflow: 'hidden' }}>
818
+ {/* Project Header */}
819
+ <div style={{
820
+ padding: '16px 20px',
821
+ background: 'linear-gradient(135deg, #fffbeb, #fef3c7)',
822
+ borderBottom: '1px solid var(--yellow-border)',
823
+ }}>
824
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
825
+ <div>
826
+ <div style={{ fontSize: 17, fontWeight: 700, marginBottom: 6 }}>{project.name}</div>
827
+ <div style={{ display: 'flex', gap: 6 }}>
828
+ <span className="badge badge-yellow">{project.failures.length} failures</span>
829
+ <span className="badge badge-blue">{project.skills.length} skills</span>
830
+ <span className="badge badge-green">{resolvedPct}% resolved</span>
831
+ </div>
832
+ </div>
833
+ {unresolved.length >= 3 && (
834
+ <div style={{
835
+ padding: '6px 14px', borderRadius: 8,
836
+ background: 'var(--green-light)', color: 'var(--green)',
837
+ fontSize: 11, fontWeight: 600,
838
+ }}>
839
+ Ready to specialize →
840
+ </div>
841
+ )}
842
+ </div>
843
+ </div>
844
+
845
+ <div className="card-body">
846
+ {/* Skills */}
847
+ <div className="section-label">Skills Involved</div>
848
+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 16 }}>
849
+ {project.skills.map(s => {
850
+ const node = allNodes.find(n => n.id === s)
851
+ return (
852
+ <div key={s} onClick={() => onSelect(s)} style={{
853
+ padding: '6px 12px', borderRadius: 8, background: 'var(--bg-section)',
854
+ border: `1px solid ${selected === s ? 'var(--purple)' : 'var(--border)'}`,
855
+ fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer',
856
+ transition: 'all 0.15s',
857
+ }}>
858
+ <span style={{ fontWeight: 500 }}>{s}</span>
859
+ {node && (
860
+ <span style={{ fontSize: 11, fontWeight: 700, color: node.score >= 0.8 ? 'var(--green)' : 'var(--yellow)' }}>
861
+ {(node.score * 100).toFixed(0)}
862
+ </span>
863
+ )}
864
+ {node?.generation ? <span className="badge badge-green" style={{ fontSize: 9 }}>gen {node.generation}</span> : null}
865
+ </div>
866
+ )
867
+ })}
868
+ </div>
869
+
870
+ {/* Failures */}
871
+ <div className="section-label">Recent Failures</div>
872
+ {project.failures.slice(0, 5).map((f: any) => (
873
+ <div key={f.id} style={{
874
+ padding: '8px 12px', marginBottom: 4, borderRadius: 8,
875
+ background: 'var(--bg-section)', borderLeft: '3px solid var(--red)',
876
+ fontSize: 12,
877
+ }}>
878
+ <div style={{ fontWeight: 500, color: 'var(--text)', marginBottom: 2 }}>{f.userRequest.slice(0, 80)}</div>
879
+ <div style={{ color: 'var(--red)', fontSize: 11 }}>→ {f.correction.slice(0, 80)}</div>
880
+ </div>
881
+ ))}
882
+ </div>
883
+ </div>
884
+ )
885
+ })}
886
+ </div>
887
+ )
888
+ }
889
+
890
+ // ═══════════════════════════════════════════════════════════════════
891
+ // SUB-VIEW: Co-Evolution
892
+ // ═══════════════════════════════════════════════════════════════════
893
+
894
+ function CoEvolutionView({
895
+ generalized, evolved, original, edges, evolutionBySkill, projects,
896
+ }: {
897
+ generalized: SkillNode[]; evolved: SkillNode[]; original: SkillNode[]
898
+ edges: GraphEdge[]; evolutionBySkill: Record<string, EvolutionEntry[]>; projects: ProjectData[]
899
+ }) {
900
+ const allNodes = [...generalized, ...evolved, ...original]
901
+
902
+ // Inheritance chains: generalized → children
903
+ const inheritanceChains = generalized.map(g => {
904
+ const children = edges.filter(e => e.type === 'inherits' && e.from === g.id).map(e => allNodes.find(n => n.id === e.to)).filter(Boolean)
905
+ return { parent: g, children: children as SkillNode[] }
906
+ })
907
+
908
+ // Enhancement clusters
909
+ const enhanceEdges = edges.filter(e => e.type === 'enhances')
910
+ const enhancePairs = enhanceEdges.map(e => ({
911
+ from: allNodes.find(n => n.id === e.from),
912
+ to: allNodes.find(n => n.id === e.to),
913
+ strength: e.strength,
914
+ })).filter(p => p.from && p.to) as { from: SkillNode; to: SkillNode; strength: number }[]
915
+
916
+ // Conflict edges
917
+ const conflictEdges = edges.filter(e => e.type === 'conflicts')
918
+ const conflictPairs = conflictEdges.map(e => ({
919
+ from: allNodes.find(n => n.id === e.from),
920
+ to: allNodes.find(n => n.id === e.to),
921
+ strength: e.strength,
922
+ })).filter(p => p.from && p.to) as { from: SkillNode; to: SkillNode; strength: number }[]
923
+
924
+ // Cross-project knowledge: which skills are shared across projects
925
+ const sharedSkills: Record<string, string[]> = {}
926
+ for (const proj of projects) {
927
+ for (const s of proj.skills) {
928
+ if (!sharedSkills[s]) sharedSkills[s] = []
929
+ sharedSkills[s].push(proj.name)
930
+ }
931
+ }
932
+ const crossProjectSkills = Object.entries(sharedSkills).filter(([, projs]) => projs.length > 1)
933
+
934
+ return (
935
+ <div style={{ display: 'grid', gap: 20 }}>
936
+ {/* Knowledge Flow: Generalize ↑ / Specialize ↓ */}
937
+ {inheritanceChains.length > 0 && (
938
+ <div className="card">
939
+ <div className="card-body">
940
+ <div className="card-header-label">Knowledge Flow — Generalize ↑ Specialize ↓</div>
941
+ <div style={{ display: 'grid', gap: 12 }}>
942
+ {inheritanceChains.map(chain => (
943
+ <div key={chain.parent.id} style={{ padding: '14px 16px', background: 'var(--bg-section)', borderRadius: 10 }}>
944
+ {/* Parent */}
945
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
946
+ <div style={{
947
+ width: 8, height: 8, borderRadius: '50%',
948
+ background: 'var(--purple)', flexShrink: 0,
949
+ }} />
950
+ <span style={{ fontWeight: 700, fontSize: 14 }}>{chain.parent.name}</span>
951
+ <span style={{ fontSize: 22, fontWeight: 800, color: scoreColor(chain.parent.score), marginLeft: 'auto' }}>
952
+ {(chain.parent.score * 100).toFixed(0)}
953
+ </span>
954
+ </div>
955
+
956
+ {/* Children */}
957
+ {chain.children.length > 0 && (
958
+ <div style={{ paddingLeft: 18, borderLeft: '2px solid var(--purple-border)' }}>
959
+ {chain.children.map(child => (
960
+ <div key={child.id} style={{
961
+ display: 'flex', alignItems: 'center', gap: 10,
962
+ padding: '6px 0',
963
+ }}>
964
+ <span style={{ fontSize: 10, color: 'var(--purple)' }}>↳</span>
965
+ <span style={{ fontWeight: 500, fontSize: 13 }}>{child.name}</span>
966
+ <span className="badge badge-green" style={{ fontSize: 9 }}>gen {child.generation}</span>
967
+ <span style={{ marginLeft: 'auto', fontSize: 13, fontWeight: 700, color: scoreColor(child.score) }}>
968
+ {(child.score * 100).toFixed(0)}
969
+ </span>
970
+ </div>
971
+ ))}
972
+ </div>
973
+ )}
974
+ </div>
975
+ ))}
976
+ </div>
977
+ </div>
978
+ </div>
979
+ )}
980
+
981
+ {/* Enhancement Pairs */}
982
+ <div className="grid-2">
983
+ <div className="card">
984
+ <div className="card-body">
985
+ <div className="card-header-label">Synergies — Skills that enhance each other</div>
986
+ {enhancePairs.length === 0 ? (
987
+ <div style={{ color: 'var(--text-dim)', fontSize: 13, textAlign: 'center', padding: 20 }}>No enhancement relationships detected</div>
988
+ ) : (
989
+ enhancePairs.map((pair, i) => (
990
+ <div key={i} style={{
991
+ display: 'flex', alignItems: 'center', gap: 10,
992
+ padding: '8px 0', borderBottom: i < enhancePairs.length - 1 ? '1px solid var(--border)' : 'none',
993
+ }}>
994
+ <span style={{ fontWeight: 500, fontSize: 13 }}>{pair.from.name}</span>
995
+ <span style={{ color: 'var(--green)', fontSize: 12 }}>↔</span>
996
+ <span style={{ fontWeight: 500, fontSize: 13 }}>{pair.to.name}</span>
997
+ <span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--text-dim)' }}>
998
+ strength: {(pair.strength * 100).toFixed(0)}%
999
+ </span>
1000
+ </div>
1001
+ ))
1002
+ )}
1003
+ </div>
1004
+ </div>
1005
+
1006
+ <div className="card">
1007
+ <div className="card-body">
1008
+ <div className="card-header-label">Conflicts — Skills with tension</div>
1009
+ {conflictPairs.length === 0 ? (
1010
+ <div style={{ color: 'var(--text-dim)', fontSize: 13, textAlign: 'center', padding: 20 }}>No conflicts detected</div>
1011
+ ) : (
1012
+ conflictPairs.map((pair, i) => (
1013
+ <div key={i} style={{
1014
+ display: 'flex', alignItems: 'center', gap: 10,
1015
+ padding: '8px 0', borderBottom: i < conflictPairs.length - 1 ? '1px solid var(--border)' : 'none',
1016
+ }}>
1017
+ <span style={{ fontWeight: 500, fontSize: 13 }}>{pair.from.name}</span>
1018
+ <span style={{ color: 'var(--red)', fontSize: 12 }}>⚡</span>
1019
+ <span style={{ fontWeight: 500, fontSize: 13 }}>{pair.to.name}</span>
1020
+ <span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--text-dim)' }}>
1021
+ strength: {(pair.strength * 100).toFixed(0)}%
1022
+ </span>
1023
+ </div>
1024
+ ))
1025
+ )}
1026
+ </div>
1027
+ </div>
1028
+ </div>
1029
+
1030
+ {/* Cross-Project Knowledge Transfer */}
1031
+ {crossProjectSkills.length > 0 && (
1032
+ <div className="card">
1033
+ <div className="card-body">
1034
+ <div className="card-header-label">Cross-Project Knowledge Transfer</div>
1035
+ <div style={{ display: 'grid', gap: 8 }}>
1036
+ {crossProjectSkills.map(([skillId, projs]) => {
1037
+ const node = allNodes.find(n => n.id === skillId)
1038
+ return (
1039
+ <div key={skillId} style={{
1040
+ display: 'flex', alignItems: 'center', gap: 12,
1041
+ padding: '10px 14px', background: 'var(--bg-section)', borderRadius: 8,
1042
+ }}>
1043
+ <span style={{ fontWeight: 600, fontSize: 13 }}>{node?.name ?? skillId}</span>
1044
+ <div style={{ display: 'flex', gap: 4, marginLeft: 'auto' }}>
1045
+ {projs.map(p => <span key={p} className="badge badge-yellow">{p}</span>)}
1046
+ </div>
1047
+ </div>
1048
+ )
1049
+ })}
1050
+ </div>
1051
+ </div>
1052
+ </div>
1053
+ )}
1054
+
1055
+ {/* Evolution Activity by Skill */}
1056
+ <div className="card">
1057
+ <div className="card-body">
1058
+ <div className="card-header-label">Evolution Activity</div>
1059
+ <div style={{ display: 'grid', gap: 6 }}>
1060
+ {allNodes.sort((a, b) => (evolutionBySkill[b.id]?.length ?? 0) - (evolutionBySkill[a.id]?.length ?? 0)).map(node => {
1061
+ const evo = evolutionBySkill[node.id] ?? []
1062
+ if (evo.length === 0) return null
1063
+ const accepted = evo.filter(e => e.outcome === 'accepted').length
1064
+ return (
1065
+ <div key={node.id} style={{
1066
+ display: 'flex', alignItems: 'center', gap: 12,
1067
+ padding: '8px 14px', background: 'var(--bg-section)', borderRadius: 8,
1068
+ }}>
1069
+ <span style={{ fontWeight: 500, fontSize: 13, minWidth: 160 }}>{node.name}</span>
1070
+ <div style={{ flex: 1, display: 'flex', gap: 3 }}>
1071
+ {evo.map((e, i) => (
1072
+ <div key={i} style={{
1073
+ width: 16, height: 16, borderRadius: 4,
1074
+ background: e.outcome === 'accepted' ? 'var(--green-light)' : 'var(--red-light)',
1075
+ border: `1px solid ${e.outcome === 'accepted' ? 'var(--green-border)' : 'var(--red-border)'}`,
1076
+ }} title={`${e.action}: ${e.outcome}`} />
1077
+ ))}
1078
+ </div>
1079
+ <span style={{ fontSize: 11, color: 'var(--text-dim)', whiteSpace: 'nowrap' }}>
1080
+ {accepted}/{evo.length} accepted
1081
+ </span>
1082
+ </div>
1083
+ )
1084
+ })}
1085
+ </div>
1086
+ </div>
1087
+ </div>
1088
+ </div>
1089
+ )
1090
+ }