@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.
- package/README.md +11 -1
- package/bin/intent.js +20 -0
- package/package.json +11 -5
- package/skills/agentpack-cli/SKILL.md +4 -1
- package/skills/authoring-skillgraphs-from-knowledge/SKILL.md +148 -0
- package/skills/authoring-skillgraphs-from-knowledge/references/authored-metadata.md +6 -0
- package/skills/developing-and-testing-skills/SKILL.md +109 -0
- package/skills/developing-and-testing-skills/references/local-workbench.md +7 -0
- package/skills/getting-started-skillgraphs/SKILL.md +115 -0
- package/skills/getting-started-skillgraphs/references/command-routing.md +7 -0
- package/skills/identifying-skill-opportunities/SKILL.md +119 -0
- package/skills/identifying-skill-opportunities/references/capability-boundaries.md +6 -0
- package/skills/maintaining-skillgraph-freshness/SKILL.md +110 -0
- package/skills/repairing-broken-skill-or-plugin-state/SKILL.md +112 -0
- package/skills/repairing-broken-skill-or-plugin-state/references/diagnostic-flows.md +6 -0
- package/skills/shipping-production-plugins-and-packages/SKILL.md +123 -0
- package/skills/shipping-production-plugins-and-packages/references/plugin-delivery.md +6 -0
- package/src/application/skills/build-skill-workbench-model.js +194 -0
- package/src/application/skills/run-skill-workbench-action.js +23 -0
- package/src/application/skills/start-skill-dev-workbench.js +192 -0
- package/src/cli.js +1 -1
- package/src/commands/skills.js +7 -1
- package/src/dashboard/App.jsx +343 -0
- package/src/dashboard/components/Breadcrumbs.jsx +45 -0
- package/src/dashboard/components/ControlStrip.jsx +153 -0
- package/src/dashboard/components/InspectorPanel.jsx +203 -0
- package/src/dashboard/components/SkillGraph.jsx +567 -0
- package/src/dashboard/components/Tooltip.jsx +111 -0
- package/src/dashboard/dist/dashboard.js +26692 -0
- package/src/dashboard/index.html +81 -0
- package/src/dashboard/lib/api.js +19 -0
- package/src/dashboard/lib/router.js +15 -0
- package/src/dashboard/main.jsx +4 -0
- package/src/domain/plugins/load-plugin-definition.js +163 -0
- package/src/domain/plugins/plugin-diagnostic-error.js +18 -0
- package/src/domain/plugins/plugin-requirements.js +15 -0
- package/src/domain/skills/skill-graph.js +1 -0
- package/src/infrastructure/runtime/open-browser.js +20 -0
- package/src/infrastructure/runtime/skill-dev-workbench-server.js +96 -0
- package/src/infrastructure/runtime/watch-skill-workbench.js +68 -0
- package/src/lib/plugins.js +19 -28
- package/src/lib/skills.js +60 -12
- 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
|
+
}
|