@alavida/agentpack 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +11 -1
  2. package/bin/intent.js +20 -0
  3. package/package.json +11 -5
  4. package/skills/agentpack-cli/SKILL.md +4 -1
  5. package/skills/authoring-skillgraphs-from-knowledge/SKILL.md +148 -0
  6. package/skills/authoring-skillgraphs-from-knowledge/references/authored-metadata.md +6 -0
  7. package/skills/developing-and-testing-skills/SKILL.md +109 -0
  8. package/skills/developing-and-testing-skills/references/local-workbench.md +7 -0
  9. package/skills/getting-started-skillgraphs/SKILL.md +115 -0
  10. package/skills/getting-started-skillgraphs/references/command-routing.md +7 -0
  11. package/skills/identifying-skill-opportunities/SKILL.md +119 -0
  12. package/skills/identifying-skill-opportunities/references/capability-boundaries.md +6 -0
  13. package/skills/maintaining-skillgraph-freshness/SKILL.md +110 -0
  14. package/skills/repairing-broken-skill-or-plugin-state/SKILL.md +112 -0
  15. package/skills/repairing-broken-skill-or-plugin-state/references/diagnostic-flows.md +6 -0
  16. package/skills/shipping-production-plugins-and-packages/SKILL.md +123 -0
  17. package/skills/shipping-production-plugins-and-packages/references/plugin-delivery.md +6 -0
  18. package/src/application/skills/build-skill-workbench-model.js +194 -0
  19. package/src/application/skills/run-skill-workbench-action.js +23 -0
  20. package/src/application/skills/start-skill-dev-workbench.js +192 -0
  21. package/src/cli.js +1 -1
  22. package/src/commands/skills.js +7 -1
  23. package/src/dashboard/App.jsx +343 -0
  24. package/src/dashboard/components/Breadcrumbs.jsx +45 -0
  25. package/src/dashboard/components/ControlStrip.jsx +153 -0
  26. package/src/dashboard/components/InspectorPanel.jsx +203 -0
  27. package/src/dashboard/components/SkillGraph.jsx +567 -0
  28. package/src/dashboard/components/Tooltip.jsx +111 -0
  29. package/src/dashboard/dist/dashboard.js +26692 -0
  30. package/src/dashboard/index.html +81 -0
  31. package/src/dashboard/lib/api.js +19 -0
  32. package/src/dashboard/lib/router.js +15 -0
  33. package/src/dashboard/main.jsx +4 -0
  34. package/src/domain/plugins/load-plugin-definition.js +163 -0
  35. package/src/domain/plugins/plugin-diagnostic-error.js +18 -0
  36. package/src/domain/plugins/plugin-requirements.js +15 -0
  37. package/src/domain/skills/skill-graph.js +1 -0
  38. package/src/infrastructure/runtime/open-browser.js +20 -0
  39. package/src/infrastructure/runtime/skill-dev-workbench-server.js +96 -0
  40. package/src/infrastructure/runtime/watch-skill-workbench.js +68 -0
  41. package/src/lib/plugins.js +19 -28
  42. package/src/lib/skills.js +60 -12
  43. package/src/utils/errors.js +33 -1
@@ -0,0 +1,567 @@
1
+ import { useEffect, useRef, useCallback } from 'react';
2
+ import * as d3 from 'd3';
3
+
4
+ const STATUS_COLORS = {
5
+ current: '#8fa67e',
6
+ stale: '#d4a45e',
7
+ affected: '#c4956e',
8
+ changed: '#d4a45e',
9
+ unknown: '#9a9488',
10
+ };
11
+
12
+ const SOURCE_COLOR = '#7a9abb';
13
+ const SOURCE_CHANGED_COLOR = '#c45454'; // red — urgent indicator
14
+
15
+ const GLOW_COLORS = {
16
+ current: { color: '#8fa67e', opacity: 0.6 },
17
+ stale: { color: '#d4a45e', opacity: 0.6 },
18
+ affected: { color: '#c4956e', opacity: 0.5 },
19
+ changed: { color: '#c45454', opacity: 0.7 },
20
+ unknown: { color: '#9a9488', opacity: 0.4 },
21
+ source: { color: '#7a9abb', opacity: 0.5 },
22
+ sourceChanged: { color: '#c45454', opacity: 0.7 },
23
+ };
24
+
25
+ function nodeRadius(node) {
26
+ if (node.type === 'skill') return 14;
27
+ return 9;
28
+ }
29
+
30
+ function nodeColor(node) {
31
+ if (node.type === 'source') return SOURCE_COLOR;
32
+ return STATUS_COLORS[node.status] || STATUS_COLORS.unknown;
33
+ }
34
+
35
+ function isFilled(node) {
36
+ if (node.type === 'source') return true;
37
+ return node.status === 'current' || node.status === 'unknown';
38
+ }
39
+
40
+ function diamondPath(size) {
41
+ return `M0,${-size} L${size},0 L0,${size} L${-size},0 Z`;
42
+ }
43
+
44
+ function buildTreeHierarchy(model) {
45
+ if (!model || !model.selected) return null;
46
+
47
+ const sourceNodes = model.nodes.filter((n) => n.type === 'source');
48
+ const edgesBySource = new Map();
49
+
50
+ for (const edge of model.edges) {
51
+ if (edge.kind !== 'requires') continue;
52
+ if (!edgesBySource.has(edge.source)) edgesBySource.set(edge.source, []);
53
+ edgesBySource.get(edge.source).push(edge.target);
54
+ }
55
+
56
+ const treeChildren = new Map();
57
+ const visited = new Set();
58
+ const crossLinks = [];
59
+ const queue = [model.selected.id];
60
+ visited.add(model.selected.id);
61
+
62
+ while (queue.length > 0) {
63
+ const parentId = queue.shift();
64
+ const childIds = edgesBySource.get(parentId) || [];
65
+
66
+ for (const childId of childIds) {
67
+ if (visited.has(childId)) {
68
+ crossLinks.push({ source: parentId, target: childId });
69
+ continue;
70
+ }
71
+ visited.add(childId);
72
+ if (!treeChildren.has(parentId)) treeChildren.set(parentId, []);
73
+ treeChildren.get(parentId).push(childId);
74
+ queue.push(childId);
75
+ }
76
+ }
77
+
78
+ const nodeMap = new Map(model.nodes.map((n) => [n.id, n]));
79
+
80
+ function buildHierarchy(id) {
81
+ const node = nodeMap.get(id);
82
+ const children = (treeChildren.get(id) || []).map(buildHierarchy);
83
+ return { data: node, children: children.length > 0 ? children : undefined };
84
+ }
85
+
86
+ const rootHierarchy = buildHierarchy(model.selected.id);
87
+
88
+ return { rootHierarchy, sourceNodes, crossLinks, nodeMap };
89
+ }
90
+
91
+ function getThemeColors() {
92
+ const s = getComputedStyle(document.documentElement);
93
+ return {
94
+ current: s.getPropertyValue('--status-current').trim(),
95
+ stale: s.getPropertyValue('--status-stale').trim(),
96
+ affected: s.getPropertyValue('--status-affected').trim(),
97
+ unknown: s.getPropertyValue('--status-unknown').trim(),
98
+ provenance: s.getPropertyValue('--edge-provenance').trim(),
99
+ requires: s.getPropertyValue('--edge-requires').trim(),
100
+ text: s.getPropertyValue('--text').trim(),
101
+ textDim: s.getPropertyValue('--text-dim').trim(),
102
+ };
103
+ }
104
+
105
+ export function SkillGraph({
106
+ model,
107
+ selectedId,
108
+ onSelect,
109
+ onHover,
110
+ onHoverEnd,
111
+ labelsVisible,
112
+ knowledgeVisible,
113
+ resetZoomSignal,
114
+ }) {
115
+ const svgRef = useRef(null);
116
+ const zoomRef = useRef(null);
117
+
118
+ const resetZoom = useCallback(() => {
119
+ if (!svgRef.current || !zoomRef.current) return;
120
+ const svg = d3.select(svgRef.current);
121
+ svg.transition().duration(500).call(
122
+ zoomRef.current.transform,
123
+ zoomRef.current.__initialTransform || d3.zoomIdentity
124
+ );
125
+ }, []);
126
+
127
+ useEffect(() => {
128
+ if (resetZoomSignal > 0) resetZoom();
129
+ }, [resetZoomSignal, resetZoom]);
130
+
131
+ useEffect(() => {
132
+ if (!model || !svgRef.current) return;
133
+
134
+ const result = buildTreeHierarchy(model);
135
+ if (!result) return;
136
+
137
+ const { rootHierarchy, sourceNodes, crossLinks } = result;
138
+ const width = window.innerWidth;
139
+ const height = window.innerHeight;
140
+ const theme = getThemeColors();
141
+
142
+ const svg = d3.select(svgRef.current);
143
+ svg.selectAll('*').remove();
144
+ svg.attr('width', width).attr('height', height);
145
+
146
+ // ─── DEFS (glow filters) ───
147
+ const defs = svg.append('defs');
148
+ for (const [status, glowConfig] of Object.entries(GLOW_COLORS)) {
149
+ const filter = defs.append('filter')
150
+ .attr('id', `glow-${status}`)
151
+ .attr('x', '-50%').attr('y', '-50%')
152
+ .attr('width', '200%').attr('height', '200%');
153
+ filter.append('feGaussianBlur')
154
+ .attr('stdDeviation', 5)
155
+ .attr('result', 'blur');
156
+ filter.append('feFlood')
157
+ .attr('flood-color', glowConfig.color)
158
+ .attr('flood-opacity', glowConfig.opacity);
159
+ filter.append('feComposite')
160
+ .attr('in2', 'blur')
161
+ .attr('operator', 'in');
162
+ const merge = filter.append('feMerge');
163
+ merge.append('feMergeNode');
164
+ merge.append('feMergeNode').attr('in', 'SourceGraphic');
165
+ }
166
+
167
+ // ─── ZOOM ───
168
+ const zoom = d3.zoom()
169
+ .scaleExtent([0.15, 3])
170
+ .on('zoom', (e) => g.attr('transform', e.transform));
171
+ zoomRef.current = zoom;
172
+ svg.call(zoom);
173
+ svg.style('cursor', 'grab');
174
+ svg.on('mousedown.cursor', () => svg.style('cursor', 'grabbing'));
175
+ svg.on('mouseup.cursor', () => svg.style('cursor', 'grab'));
176
+
177
+ const g = svg.append('g');
178
+
179
+ // ─── TREE LAYOUT (skills only) ───
180
+ const hierarchy = d3.hierarchy(rootHierarchy);
181
+ const treeWidth = Math.max(width * 0.5, 400);
182
+ const treeHeight = Math.max(hierarchy.height * 180, 280);
183
+ const treeLayout = d3.tree()
184
+ .size([treeWidth, treeHeight])
185
+ .separation((a, b) => {
186
+ if (a.depth === 0) return 3;
187
+ return a.parent === b.parent ? 1.5 : 2;
188
+ });
189
+
190
+ treeLayout(hierarchy);
191
+
192
+ // ─── POSITION SKILL NODES ───
193
+ const posMap = new Map();
194
+ const sourceBandY = 30;
195
+ const treeTopPad = 240; // large gap between source band and tree
196
+
197
+ hierarchy.descendants().forEach((d) => {
198
+ posMap.set(d.data.data.id, { x: d.x, y: d.y + treeTopPad });
199
+ });
200
+
201
+ // ─── Build set of changed source IDs ───
202
+ const changedSourceIds = new Set(
203
+ sourceNodes.filter((n) => n.status === 'changed').map((n) => n.id)
204
+ );
205
+
206
+ // ─── FORCE-SIMULATE SOURCE POSITIONS (source-only, no link forces) ───
207
+ const provenanceEdges = model.edges.filter((e) => e.kind === 'provenance');
208
+ const hasSources = sourceNodes.length > 0;
209
+
210
+ if (hasSources) {
211
+ // Compute each source's ideal X as the average X of its consumers
212
+ const sourceSimNodes = sourceNodes.map((src) => {
213
+ const consumers = provenanceEdges
214
+ .filter((e) => e.source === src.id)
215
+ .map((e) => posMap.get(e.target))
216
+ .filter(Boolean);
217
+ const idealX = consumers.length > 0
218
+ ? consumers.reduce((s, p) => s + p.x, 0) / consumers.length
219
+ : treeWidth / 2;
220
+ return { id: src.id, x: idealX, y: sourceBandY, idealX };
221
+ });
222
+
223
+ // Source-only simulation: spread apart, stay in band
224
+ const sourceSim = d3.forceSimulation(sourceSimNodes)
225
+ .force('collide', d3.forceCollide(70))
226
+ .force('x', d3.forceX((d) => d.idealX).strength(0.3))
227
+ .force('y', d3.forceY(sourceBandY).strength(0.8))
228
+ .force('charge', d3.forceManyBody().strength(-100))
229
+ .stop();
230
+
231
+ for (let i = 0; i < 200; i++) sourceSim.tick();
232
+
233
+ sourceSimNodes.forEach((sn) => {
234
+ posMap.set(sn.id, { x: sn.x, y: sn.y });
235
+ });
236
+ }
237
+
238
+ // ─── SEPARATOR LINE between source zone and skill zone ───
239
+ if (hasSources && knowledgeVisible) {
240
+ const separatorY = treeTopPad - 60;
241
+ g.append('line')
242
+ .attr('x1', -200)
243
+ .attr('y1', separatorY)
244
+ .attr('x2', treeWidth + 200)
245
+ .attr('y2', separatorY)
246
+ .attr('stroke', theme.provenance)
247
+ .attr('stroke-width', 0.5)
248
+ .attr('stroke-dasharray', '3 6')
249
+ .attr('opacity', 0.12)
250
+ .style('pointer-events', 'none');
251
+ }
252
+
253
+ // ─── PROVENANCE EDGES ───
254
+ // Changed-source edges are amber and clearly visible; others are near-invisible
255
+ const provGroup = g.append('g').attr('class', 'provenance-edges');
256
+ if (knowledgeVisible) {
257
+ provGroup.selectAll('path')
258
+ .data(provenanceEdges)
259
+ .join('path')
260
+ .attr('class', 'edge provenance-edge')
261
+ .attr('d', (e) => {
262
+ const s = posMap.get(e.source);
263
+ const t = posMap.get(e.target);
264
+ if (!s || !t) return '';
265
+ const midY = s.y + (t.y - s.y) * 0.5;
266
+ return `M${s.x},${s.y + 8} C${s.x},${midY} ${t.x},${midY} ${t.x},${t.y - 14}`;
267
+ })
268
+ .attr('fill', 'none')
269
+ .attr('stroke', (e) => changedSourceIds.has(e.source) ? SOURCE_CHANGED_COLOR : theme.provenance)
270
+ .attr('stroke-dasharray', (e) => changedSourceIds.has(e.source) ? '4 3' : '2 4')
271
+ .attr('opacity', (e) => {
272
+ if (changedSourceIds.has(e.source)) return 0.55; // dirty provenance always visible
273
+ if (e.target === model.selected.id) return 0.35;
274
+ return 0.08;
275
+ })
276
+ .attr('stroke-width', (e) => {
277
+ if (changedSourceIds.has(e.source)) return 2;
278
+ if (e.target === model.selected.id) return 1.5;
279
+ return 1;
280
+ })
281
+ .style('transition', 'opacity 200ms ease, stroke-width 200ms ease');
282
+ }
283
+
284
+ // ─── TREE EDGES (vertical bezier) ───
285
+ const treeGroup = g.append('g').attr('class', 'tree-edges');
286
+ treeGroup.selectAll('path')
287
+ .data(hierarchy.links())
288
+ .join('path')
289
+ .attr('class', 'edge tree-edge')
290
+ .attr('d', (d) => {
291
+ const sx = d.source.x;
292
+ const sy = d.source.y + treeTopPad;
293
+ const tx = d.target.x;
294
+ const ty = d.target.y + treeTopPad;
295
+ const midY = sy + (ty - sy) * 0.5;
296
+ return `M${sx},${sy} C${sx},${midY} ${tx},${midY} ${tx},${ty}`;
297
+ })
298
+ .attr('fill', 'none')
299
+ .attr('stroke', theme.requires)
300
+ .attr('stroke-width', (d) => d.target.depth <= 1 ? 2.5 : 1.5)
301
+ .attr('opacity', (d) => {
302
+ const depth = d.target.depth;
303
+ if (depth <= 1) return 0.5;
304
+ if (depth === 2) return 0.35;
305
+ return 0.2;
306
+ })
307
+ .style('transition', 'opacity 200ms ease, stroke-width 200ms ease');
308
+
309
+ // ─── CROSS-LINKS (shared deps — dashed green) ───
310
+ const crossGroup = g.append('g').attr('class', 'cross-edges');
311
+ crossGroup.selectAll('path')
312
+ .data(crossLinks)
313
+ .join('path')
314
+ .attr('class', 'edge cross-edge')
315
+ .attr('d', (e) => {
316
+ const s = posMap.get(e.source);
317
+ const t = posMap.get(e.target);
318
+ if (!s || !t) return '';
319
+ const midY = Math.max(s.y, t.y) + 40;
320
+ return `M${s.x},${s.y} C${s.x},${midY} ${t.x},${midY} ${t.x},${t.y}`;
321
+ })
322
+ .attr('fill', 'none')
323
+ .attr('stroke', theme.requires)
324
+ .attr('stroke-width', 1.5)
325
+ .attr('stroke-dasharray', '6 4')
326
+ .attr('opacity', 0.2)
327
+ .style('transition', 'opacity 200ms ease');
328
+
329
+ // ─── SOURCE NODES (floating diamonds in their own band) ───
330
+ if (hasSources && knowledgeVisible) {
331
+ const sourceGroup = g.append('g').attr('class', 'source-nodes');
332
+ const sourceGs = sourceGroup.selectAll('g')
333
+ .data(sourceNodes)
334
+ .join('g')
335
+ .attr('transform', (n) => {
336
+ const p = posMap.get(n.id);
337
+ return `translate(${p.x},${p.y})`;
338
+ })
339
+ .style('cursor', 'pointer')
340
+ .on('click', (_, n) => onSelect(n.id))
341
+ .on('mouseenter', (event, n) => {
342
+ highlightConnected(n, model, posMap, g);
343
+ onHover(n, { x: event.clientX, y: event.clientY });
344
+ })
345
+ .on('mousemove', (event, n) => onHover(n, { x: event.clientX, y: event.clientY }))
346
+ .on('mouseleave', () => {
347
+ clearHighlight(g, model.selected.id, changedSourceIds);
348
+ onHoverEnd();
349
+ });
350
+
351
+ // Pulsing glow ring for changed sources
352
+ sourceGs.filter((n) => n.status === 'changed')
353
+ .append('path')
354
+ .attr('d', diamondPath(14))
355
+ .attr('fill', 'none')
356
+ .attr('stroke', SOURCE_CHANGED_COLOR)
357
+ .attr('stroke-width', 1.5)
358
+ .style('animation', 'stale-pulse 2s ease-in-out infinite');
359
+
360
+ sourceGs.append('path')
361
+ .attr('class', 'source-shape')
362
+ .attr('d', diamondPath(8))
363
+ .attr('fill', (n) => {
364
+ if (n.status === 'changed') return SOURCE_CHANGED_COLOR;
365
+ return `${theme.provenance}55`;
366
+ })
367
+ .attr('stroke', (n) => n.status === 'changed' ? SOURCE_CHANGED_COLOR : theme.provenance)
368
+ .attr('stroke-width', 1.5)
369
+ .attr('filter', (n) => n.status === 'changed' ? 'url(#glow-sourceChanged)' : null)
370
+ .style('transition', 'filter 200ms ease');
371
+
372
+ sourceGs.append('text')
373
+ .attr('class', 'node-label')
374
+ .text((n) => n.path.split('/').slice(-1)[0].replace('.md', ''))
375
+ .attr('x', 0)
376
+ .attr('y', -16)
377
+ .attr('text-anchor', 'middle')
378
+ .attr('fill', (n) => n.status === 'changed' ? SOURCE_CHANGED_COLOR : theme.provenance)
379
+ .attr('font-family', 'var(--font-mono)')
380
+ .attr('font-size', 10)
381
+ .attr('opacity', 1)
382
+ .style('pointer-events', 'none')
383
+ .style('display', labelsVisible ? null : 'none');
384
+ }
385
+
386
+ // ─── SKILL / DEPENDENCY NODES (circles) ───
387
+ const skillNodeData = hierarchy.descendants();
388
+ const nodeGroup = g.append('g').attr('class', 'skill-nodes');
389
+ const nodeGs = nodeGroup.selectAll('g')
390
+ .data(skillNodeData)
391
+ .join('g')
392
+ .attr('transform', (d) => `translate(${d.x},${d.y + treeTopPad})`)
393
+ .style('cursor', 'pointer')
394
+ .on('click', (_, d) => onSelect(d.data.data.id))
395
+ .on('mouseenter', (event, d) => {
396
+ highlightConnected(d.data.data, model, posMap, g);
397
+ onHover(d.data.data, { x: event.clientX, y: event.clientY });
398
+ })
399
+ .on('mousemove', (event, d) => onHover(d.data.data, { x: event.clientX, y: event.clientY }))
400
+ .on('mouseleave', () => {
401
+ clearHighlight(g, model.selected.id, changedSourceIds);
402
+ onHoverEnd();
403
+ });
404
+
405
+ // Stale glow ring
406
+ nodeGs.filter((d) => d.data.data.status === 'stale')
407
+ .append('circle')
408
+ .attr('r', (d) => nodeRadius(d.data.data) + 7)
409
+ .attr('fill', 'none')
410
+ .attr('stroke', STATUS_COLORS.stale)
411
+ .attr('stroke-width', 1.5)
412
+ .style('animation', 'stale-pulse 2s ease-in-out infinite');
413
+
414
+ // Main node circle
415
+ nodeGs.append('circle')
416
+ .attr('class', 'node-circle')
417
+ .attr('r', (d) => nodeRadius(d.data.data))
418
+ .attr('fill', (d) => {
419
+ const n = d.data.data;
420
+ if (n.status === 'affected') return 'transparent';
421
+ return isFilled(n) ? nodeColor(n) : 'transparent';
422
+ })
423
+ .attr('stroke', (d) => {
424
+ const n = d.data.data;
425
+ if (n.id === selectedId) return theme.text;
426
+ return nodeColor(n);
427
+ })
428
+ .attr('stroke-width', (d) => {
429
+ const n = d.data.data;
430
+ if (n.id === selectedId) return 3;
431
+ if (n.type === 'skill') return 2;
432
+ if (n.status === 'affected' || !isFilled(n)) return 1.5;
433
+ return 0;
434
+ })
435
+ .style('transition', 'r 200ms ease, filter 200ms ease');
436
+
437
+ // Skill labels — above node
438
+ nodeGs.append('text')
439
+ .attr('class', 'node-label')
440
+ .text((d) => d.data.data.name || d.data.data.packageName)
441
+ .attr('text-anchor', 'middle')
442
+ .attr('y', (d) => -nodeRadius(d.data.data) - 12)
443
+ .attr('fill', (d) => d.data.data.id === model.selected.id ? theme.text : theme.textDim)
444
+ .attr('font-family', 'var(--font-mono)')
445
+ .attr('font-size', (d) => d.data.data.type === 'skill' ? 13 : 11)
446
+ .attr('font-weight', (d) => d.data.data.type === 'skill' ? 600 : 400)
447
+ .style('pointer-events', 'none')
448
+ .style('display', labelsVisible ? null : 'none');
449
+
450
+ // ─── INITIAL TRANSFORM (auto-center) ───
451
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
452
+ for (const [, pos] of posMap) {
453
+ if (pos.x < minX) minX = pos.x;
454
+ if (pos.x > maxX) maxX = pos.x;
455
+ if (pos.y < minY) minY = pos.y;
456
+ if (pos.y > maxY) maxY = pos.y;
457
+ }
458
+ minX -= 80;
459
+ maxX += 80;
460
+ minY -= 50;
461
+ maxY += 50;
462
+
463
+ const contentWidth = maxX - minX;
464
+ const contentHeight = maxY - minY;
465
+ const scaleX = (width - 80) / contentWidth;
466
+ const scaleY = (height - 140) / contentHeight;
467
+ const scale = Math.min(scaleX, scaleY, 1) * 0.85;
468
+ const centerX = (minX + maxX) / 2;
469
+ const centerY = (minY + maxY) / 2;
470
+
471
+ const initialTransform = d3.zoomIdentity
472
+ .translate(width / 2 - centerX * scale, height * 0.42 - centerY * scale)
473
+ .scale(scale);
474
+ zoom.__initialTransform = initialTransform;
475
+ svg.call(zoom.transform, initialTransform);
476
+
477
+ }, [model, selectedId, labelsVisible, knowledgeVisible, onSelect, onHover, onHoverEnd]);
478
+
479
+ return <svg ref={svgRef} style={{ flex: 1, minHeight: 0 }} />;
480
+ }
481
+
482
+ function highlightConnected(node, model, posMap, g) {
483
+ const connected = new Set([node.id]);
484
+ for (const edge of model.edges) {
485
+ if (edge.source === node.id) connected.add(edge.target);
486
+ if (edge.target === node.id) connected.add(edge.source);
487
+ }
488
+
489
+ if (node.type !== 'source') {
490
+ for (const edge of model.edges) {
491
+ if (edge.kind === 'requires' && edge.target === node.id) {
492
+ connected.add(edge.source);
493
+ }
494
+ }
495
+ }
496
+
497
+ g.selectAll('.source-nodes g').style('opacity', (n) => connected.has(n.id) ? 1 : 0.08);
498
+ g.selectAll('.skill-nodes g').style('opacity', (d) => connected.has(d.data.data.id) ? 1 : 0.08);
499
+
500
+ // Provenance edges: bright for connected, hidden for rest
501
+ g.selectAll('.provenance-edge').style('opacity', function () {
502
+ const data = d3.select(this).datum();
503
+ if (!data) return 0;
504
+ return (connected.has(data.source) && connected.has(data.target)) ? 0.6 : 0;
505
+ }).style('stroke-width', function () {
506
+ const data = d3.select(this).datum();
507
+ if (!data) return 1;
508
+ return (connected.has(data.source) && connected.has(data.target)) ? 2 : 1;
509
+ });
510
+
511
+ // Tree + cross edges
512
+ g.selectAll('.tree-edge').style('opacity', function () {
513
+ const data = d3.select(this).datum();
514
+ if (!data?.source?.data?.data) return 0.03;
515
+ const sId = data.source.data.data.id;
516
+ const tId = data.target.data.data.id;
517
+ return (connected.has(sId) && connected.has(tId)) ? 0.9 : 0.03;
518
+ });
519
+
520
+ g.selectAll('.cross-edge').style('opacity', function () {
521
+ const data = d3.select(this).datum();
522
+ if (!data) return 0.03;
523
+ return (connected.has(data.source) && connected.has(data.target)) ? 0.9 : 0.03;
524
+ });
525
+
526
+ const glowKey = node.type === 'source'
527
+ ? (node.status === 'changed' ? 'sourceChanged' : 'source')
528
+ : (node.status || 'unknown');
529
+ g.selectAll('.skill-nodes g')
530
+ .filter((d) => d.data.data.id === node.id)
531
+ .select('.node-circle')
532
+ .attr('filter', `url(#glow-${glowKey})`);
533
+
534
+ g.selectAll('.source-nodes g')
535
+ .filter((n) => n.id === node.id)
536
+ .select('.source-shape')
537
+ .attr('filter', `url(#glow-${node.type === 'source' && node.status === 'changed' ? 'sourceChanged' : 'source'})`);
538
+ }
539
+
540
+ function clearHighlight(g, selectedSkillId, changedSourceIds) {
541
+ g.selectAll('.source-nodes g').style('opacity', 1);
542
+ g.selectAll('.skill-nodes g').style('opacity', 1);
543
+ g.selectAll('.provenance-edge')
544
+ .style('opacity', function () {
545
+ const d = d3.select(this).datum();
546
+ if (!d) return 0.08;
547
+ if (changedSourceIds && changedSourceIds.has(d.source)) return 0.55;
548
+ if (d.target === selectedSkillId) return 0.35;
549
+ return 0.08;
550
+ })
551
+ .style('stroke-width', function () {
552
+ const d = d3.select(this).datum();
553
+ if (!d) return 1;
554
+ if (changedSourceIds && changedSourceIds.has(d.source)) return 2;
555
+ if (d.target === selectedSkillId) return 1.5;
556
+ return 1;
557
+ });
558
+ g.selectAll('.tree-edge').style('opacity', null);
559
+ g.selectAll('.cross-edge').style('opacity', 0.2);
560
+ g.selectAll('.node-circle').attr('filter', null);
561
+ // Preserve changed source glow
562
+ g.selectAll('.source-shape').attr('filter', function () {
563
+ const d = d3.select(this.parentNode).datum();
564
+ if (d && changedSourceIds && changedSourceIds.has(d.id)) return 'url(#glow-sourceChanged)';
565
+ return null;
566
+ });
567
+ }
@@ -0,0 +1,111 @@
1
+ const STATUS_COLORS = {
2
+ current: 'var(--status-current)',
3
+ stale: 'var(--status-stale)',
4
+ affected: 'var(--status-affected)',
5
+ changed: 'var(--status-stale)',
6
+ unknown: 'var(--status-unknown)',
7
+ };
8
+
9
+ export function Tooltip({ node, position }) {
10
+ if (!node || !position) return null;
11
+
12
+ const x = Math.min(position.x + 16, window.innerWidth - 380);
13
+ const y = Math.min(position.y - 10, window.innerHeight - 220);
14
+
15
+ const statusColor = STATUS_COLORS[node.status] || STATUS_COLORS.unknown;
16
+ const label = node.packageName || node.path?.split('/').slice(-1)[0] || node.id;
17
+ const description = node.description || '';
18
+ const truncated = description.length > 200 ? description.slice(0, 200) + '...' : description;
19
+
20
+ return (
21
+ <div
22
+ style={{
23
+ position: 'fixed',
24
+ left: x,
25
+ top: y,
26
+ background: 'var(--surface)',
27
+ border: '1px solid var(--border-bright)',
28
+ padding: '16px 20px',
29
+ maxWidth: 360,
30
+ zIndex: 100,
31
+ pointerEvents: 'none',
32
+ animation: 'tooltipIn 200ms ease',
33
+ }}
34
+ >
35
+ <style>{`
36
+ @keyframes tooltipIn {
37
+ from { opacity: 0; transform: translateY(4px); }
38
+ to { opacity: 1; transform: translateY(0); }
39
+ }
40
+ `}</style>
41
+ <div style={{
42
+ fontFamily: 'var(--font-body)',
43
+ fontSize: 18,
44
+ fontWeight: 400,
45
+ fontStyle: 'italic',
46
+ color: 'var(--text)',
47
+ marginBottom: 4,
48
+ }}>
49
+ {node.name || label}
50
+ </div>
51
+ <div style={{
52
+ fontFamily: 'var(--font-mono)',
53
+ fontVariant: 'small-caps',
54
+ textTransform: 'uppercase',
55
+ letterSpacing: 3,
56
+ fontSize: 9,
57
+ color: statusColor,
58
+ marginBottom: truncated ? 10 : 0,
59
+ }}>
60
+ {node.type}
61
+ </div>
62
+ {truncated && (
63
+ <div style={{
64
+ fontFamily: 'var(--font-body)',
65
+ fontSize: 14,
66
+ color: 'var(--text-dim)',
67
+ lineHeight: 1.5,
68
+ marginBottom: 10,
69
+ }}>
70
+ {truncated}
71
+ </div>
72
+ )}
73
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
74
+ <span style={{
75
+ fontFamily: 'var(--font-mono)',
76
+ fontSize: 9,
77
+ padding: '2px 8px',
78
+ letterSpacing: '0.02em',
79
+ background: `color-mix(in srgb, ${statusColor} 12%, transparent)`,
80
+ color: statusColor,
81
+ }}>
82
+ {node.status}
83
+ </span>
84
+ {node.version && (
85
+ <span style={{
86
+ fontFamily: 'var(--font-mono)',
87
+ fontSize: 9,
88
+ padding: '2px 8px',
89
+ letterSpacing: '0.02em',
90
+ background: 'rgba(255, 255, 255, 0.04)',
91
+ color: 'var(--text-dim)',
92
+ }}>
93
+ v{node.version}
94
+ </span>
95
+ )}
96
+ {node.usedBy && (
97
+ <span style={{
98
+ fontFamily: 'var(--font-mono)',
99
+ fontSize: 9,
100
+ padding: '2px 8px',
101
+ letterSpacing: '0.02em',
102
+ background: 'rgba(122, 154, 187, 0.1)',
103
+ color: 'var(--edge-provenance)',
104
+ }}>
105
+ {node.usedBy.length} skill{node.usedBy.length !== 1 ? 's' : ''}
106
+ </span>
107
+ )}
108
+ </div>
109
+ </div>
110
+ );
111
+ }