Skip to content

Commit 1b83f89

Browse files
committed
feat: intent registry — diff viewer, dependency graph, SSR loaders, search highlighting, mobile fixes
1 parent d9d8ab1 commit 1b83f89

File tree

11 files changed

+971
-128
lines changed

11 files changed

+971
-128
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"cmdk": "^1.1.1",
7070
"d3": "^7.9.0",
7171
"date-fns": "^2.30.0",
72+
"diff": "^8.0.3",
7273
"discord-interactions": "^4.4.0",
7374
"drizzle-orm": "^0.44.7",
7475
"eslint-plugin-jsx-a11y": "^6.10.2",

pnpm-lock.yaml

Lines changed: 7 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import * as React from 'react'
2+
import {
3+
forceSimulation,
4+
forceLink,
5+
forceManyBody,
6+
forceCenter,
7+
forceCollide,
8+
type SimulationNodeDatum,
9+
type SimulationLinkDatum,
10+
} from 'd3'
11+
import { Link } from '@tanstack/react-router'
12+
import { SKILL_TYPE_STYLES } from '~/routes/intent/registry/$packageName'
13+
14+
interface SkillNode {
15+
name: string
16+
type: string | null
17+
requires: Array<string> | null
18+
}
19+
20+
interface GraphNode extends SimulationNodeDatum {
21+
id: string
22+
type: string | null
23+
hasIncoming: boolean
24+
hasOutgoing: boolean
25+
}
26+
27+
interface GraphLink extends SimulationLinkDatum<GraphNode> {
28+
source: string | GraphNode
29+
target: string | GraphNode
30+
}
31+
32+
const TYPE_COLORS: Record<string, { fill: string; stroke: string }> = {
33+
core: { fill: '#8b5cf6', stroke: '#7c3aed' },
34+
'sub-skill': { fill: '#6b7280', stroke: '#4b5563' },
35+
framework: { fill: '#f59e0b', stroke: '#d97706' },
36+
lifecycle: { fill: '#10b981', stroke: '#059669' },
37+
composition: { fill: '#3b82f6', stroke: '#2563eb' },
38+
security: { fill: '#ef4444', stroke: '#dc2626' },
39+
}
40+
41+
const DEFAULT_COLOR = { fill: '#9ca3af', stroke: '#6b7280' }
42+
43+
export function SkillDependencyGraph({
44+
skills,
45+
packageName,
46+
}: {
47+
readonly skills: Array<SkillNode>
48+
readonly packageName: string
49+
}) {
50+
const svgRef = React.useRef<SVGSVGElement>(null)
51+
const [nodes, setNodes] = React.useState<Array<GraphNode>>([])
52+
const [links, setLinks] = React.useState<Array<GraphLink>>([])
53+
const [dimensions, setDimensions] = React.useState({ width: 0, height: 0 })
54+
55+
// Build graph data
56+
const { graphNodes, graphLinks, hasEdges } = React.useMemo(() => {
57+
const skillNames = new Set(skills.map((s) => s.name))
58+
const gNodes: Array<GraphNode> = skills.map((s) => ({
59+
id: s.name,
60+
type: s.type,
61+
hasIncoming: false,
62+
hasOutgoing:
63+
(s.requires?.filter((r) => skillNames.has(r)).length ?? 0) > 0,
64+
}))
65+
66+
const gLinks: Array<GraphLink> = []
67+
const incomingSet = new Set<string>()
68+
69+
for (const skill of skills) {
70+
if (!skill.requires) continue
71+
for (const req of skill.requires) {
72+
if (skillNames.has(req)) {
73+
gLinks.push({ source: skill.name, target: req })
74+
incomingSet.add(req)
75+
}
76+
}
77+
}
78+
79+
for (const node of gNodes) {
80+
node.hasIncoming = incomingSet.has(node.id)
81+
}
82+
83+
return {
84+
graphNodes: gNodes,
85+
graphLinks: gLinks,
86+
hasEdges: gLinks.length > 0,
87+
}
88+
}, [skills])
89+
90+
// Measure container
91+
const containerRef = React.useRef<HTMLDivElement>(null)
92+
93+
React.useEffect(() => {
94+
if (!containerRef.current) return
95+
const observer = new ResizeObserver((entries) => {
96+
const entry = entries[0]
97+
if (!entry) return
98+
const { width } = entry.contentRect
99+
const height = Math.min(Math.max(width * 0.6, 200), 400)
100+
setDimensions({ width, height })
101+
})
102+
observer.observe(containerRef.current)
103+
return () => observer.disconnect()
104+
}, [])
105+
106+
// Run force simulation
107+
React.useEffect(() => {
108+
if (dimensions.width === 0 || graphNodes.length === 0) return
109+
110+
const nodesCopy = graphNodes.map((n) => ({ ...n }))
111+
const linksCopy = graphLinks.map((l) => ({ ...l }))
112+
113+
const sim = forceSimulation(nodesCopy)
114+
.force(
115+
'link',
116+
forceLink<GraphNode, GraphLink>(linksCopy)
117+
.id((d) => d.id)
118+
.distance(80),
119+
)
120+
.force('charge', forceManyBody().strength(-200))
121+
.force('center', forceCenter(dimensions.width / 2, dimensions.height / 2))
122+
.force('collide', forceCollide(35))
123+
124+
sim.on('end', () => {
125+
setNodes([...nodesCopy])
126+
setLinks(
127+
linksCopy.map((l) => ({
128+
...l,
129+
source: l.source as GraphNode,
130+
target: l.target as GraphNode,
131+
})),
132+
)
133+
})
134+
135+
// Run synchronously for small graphs
136+
sim.tick(200)
137+
sim.stop()
138+
setNodes([...nodesCopy])
139+
setLinks(
140+
linksCopy.map((l) => ({
141+
...l,
142+
source: l.source as GraphNode,
143+
target: l.target as GraphNode,
144+
})),
145+
)
146+
147+
return () => {
148+
sim.stop()
149+
}
150+
}, [graphNodes, graphLinks, dimensions])
151+
152+
if (!hasEdges) return null
153+
154+
const NODE_RADIUS = 6
155+
156+
return (
157+
<div
158+
ref={containerRef}
159+
className="rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden bg-gray-50/50 dark:bg-gray-900/30"
160+
>
161+
{dimensions.width > 0 && nodes.length > 0 && (
162+
<svg
163+
ref={svgRef}
164+
width={dimensions.width}
165+
height={dimensions.height}
166+
className="block"
167+
>
168+
<defs>
169+
<marker
170+
id="arrowhead"
171+
markerWidth="8"
172+
markerHeight="6"
173+
refX="7"
174+
refY="3"
175+
orient="auto"
176+
>
177+
<path
178+
d="M0,0 L8,3 L0,6"
179+
fill="none"
180+
stroke="currentColor"
181+
strokeWidth="1"
182+
className="text-gray-400 dark:text-gray-600"
183+
/>
184+
</marker>
185+
</defs>
186+
187+
{/* Links */}
188+
{links.map((link, i) => {
189+
const source = link.source as GraphNode
190+
const target = link.target as GraphNode
191+
if (
192+
source.x == null ||
193+
source.y == null ||
194+
target.x == null ||
195+
target.y == null
196+
)
197+
return null
198+
199+
// Shorten line to stop at node edge
200+
const dx = target.x - source.x
201+
const dy = target.y - source.y
202+
const dist = Math.sqrt(dx * dx + dy * dy)
203+
if (dist === 0) return null
204+
const offsetX = (dx / dist) * (NODE_RADIUS + 4)
205+
const offsetY = (dy / dist) * (NODE_RADIUS + 4)
206+
207+
return (
208+
<line
209+
key={i}
210+
x1={source.x + offsetX}
211+
y1={source.y + offsetY}
212+
x2={target.x - offsetX}
213+
y2={target.y - offsetY}
214+
stroke="currentColor"
215+
strokeWidth={1.5}
216+
className="text-gray-300 dark:text-gray-700"
217+
markerEnd="url(#arrowhead)"
218+
/>
219+
)
220+
})}
221+
222+
{/* Nodes */}
223+
{nodes.map((node) => {
224+
if (node.x == null || node.y == null) return null
225+
const colors = TYPE_COLORS[node.type ?? ''] ?? DEFAULT_COLOR
226+
227+
return (
228+
<Link
229+
key={node.id}
230+
to="/intent/registry/$packageName/$skillName"
231+
params={{ packageName, skillName: node.id }}
232+
>
233+
<g className="cursor-pointer group">
234+
<circle
235+
cx={node.x}
236+
cy={node.y}
237+
r={NODE_RADIUS}
238+
fill={colors.fill}
239+
stroke={colors.stroke}
240+
strokeWidth={1.5}
241+
opacity={0.85}
242+
className="transition-opacity group-hover:opacity-100"
243+
/>
244+
<text
245+
x={node.x}
246+
y={node.y + NODE_RADIUS + 12}
247+
textAnchor="middle"
248+
className="text-[10px] fill-gray-500 dark:fill-gray-400 font-mono group-hover:fill-gray-900 dark:group-hover:fill-gray-100 transition-colors"
249+
>
250+
{node.id}
251+
</text>
252+
</g>
253+
</Link>
254+
)
255+
})}
256+
</svg>
257+
)}
258+
259+
{/* Legend */}
260+
<div className="flex items-center gap-3 px-3 py-2 border-t border-gray-200 dark:border-gray-800 text-[10px] text-gray-400 dark:text-gray-500">
261+
<span className="uppercase tracking-wider font-medium">
262+
Dependencies
263+
</span>
264+
<span className="text-gray-300 dark:text-gray-700">|</span>
265+
{Object.entries(SKILL_TYPE_STYLES)
266+
.filter(([type]) => skills.some((s) => s.type === type))
267+
.map(([type]) => {
268+
const colors = TYPE_COLORS[type] ?? DEFAULT_COLOR
269+
return (
270+
<span key={type} className="flex items-center gap-1">
271+
<span
272+
className="w-2 h-2 rounded-full"
273+
style={{ backgroundColor: colors.fill }}
274+
/>
275+
{type}
276+
</span>
277+
)
278+
})}
279+
</div>
280+
</div>
281+
)
282+
}

0 commit comments

Comments
 (0)