feat: add Superfluid stream edges, 0xSplits view, and rVote API client

- StreamEdge component with animated dash pattern for Superfluid flow visualization
  (planned/active/paused status, flow rate + token labels)
- SplitsView component showing recipient addresses, percentage bars, and export config
- rVote API client module (fetchSpace, fetchPassedProposals) via CORS proxy
- Stream-out handle on FunnelNode for creating stream connections
- Splits and streams display in FunnelNode edit modal
- Edge type system refactored to support allocation + stream edge data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 10:20:51 -07:00
parent f012502133
commit 8c3baed4b8
9 changed files with 435 additions and 56 deletions

View File

@ -19,8 +19,9 @@ import '@xyflow/react/dist/style.css'
import FunnelNode from './nodes/FunnelNode'
import OutcomeNode from './nodes/OutcomeNode'
import AllocationEdge from './edges/AllocationEdge'
import StreamEdge from './edges/StreamEdge'
import IntegrationPanel from './IntegrationPanel'
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig } from '@/lib/types'
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types'
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
const nodeTypes = {
@ -30,6 +31,7 @@ const nodeTypes = {
const edgeTypes = {
allocation: AllocationEdge,
stream: StreamEdge,
}
// Generate edges with proportional Sankey-style widths
@ -140,6 +142,36 @@ function generateEdges(
type: 'allocation',
})
})
// Stream edges (Superfluid visual planning)
data.streamAllocations?.forEach((stream) => {
const statusColor = stream.status === 'active' ? '#3b82f6' : stream.status === 'paused' ? '#f97316' : '#22c55e'
edges.push({
id: `stream-${node.id}-${stream.targetId}`,
source: node.id,
target: stream.targetId,
sourceHandle: 'stream-out',
animated: false,
style: {
stroke: statusColor,
strokeWidth: 3,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: statusColor,
width: 14,
height: 14,
},
data: {
flowRate: stream.flowRate,
tokenSymbol: stream.tokenSymbol,
status: stream.status,
sourceId: node.id,
targetId: stream.targetId,
},
type: 'stream',
})
})
})
return edges
@ -230,6 +262,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
id: n.id,
overflow: d.overflowAllocations,
spending: d.spendingAllocations,
streams: d.streamAllocations,
rate: d.inflowRate,
}
})
@ -247,8 +280,9 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
const isOverflow = params.sourceHandle?.startsWith('outflow')
const isSpending = params.sourceHandle === 'spending-out'
const isStream = params.sourceHandle === 'stream-out'
if (!isOverflow && !isSpending) return
if (!isOverflow && !isSpending && !isStream) return
setNodes((nds) => nds.map((node) => {
if (node.id !== params.source || node.type !== 'funnel') return node
@ -276,7 +310,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
],
},
}
} else {
} else if (isSpending) {
const existing = data.spendingAllocations || []
if (existing.some(a => a.targetId === params.target)) return node
const newPct = existing.length === 0 ? 100 : Math.floor(100 / (existing.length + 1))
@ -298,6 +332,27 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
],
},
}
} else if (isStream) {
const existing = data.streamAllocations || []
if (existing.some(s => s.targetId === params.target)) return node
return {
...node,
data: {
...data,
streamAllocations: [
...existing,
{
targetId: params.target!,
flowRate: 100,
tokenSymbol: 'DAIx',
status: 'planned' as const,
color: '#22c55e',
},
],
},
}
} else {
return node
}
}))
},
@ -306,13 +361,31 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
const onReconnect = useCallback(
(oldEdge: FlowEdge, newConnection: Connection) => {
const edgeData = oldEdge.data
const edgeData = oldEdge.data as AllocationEdgeData | undefined
if (!edgeData || !newConnection.target) return
const oldTargetId = oldEdge.target
const newTargetId = newConnection.target
if (oldTargetId === newTargetId) return
// Stream edges: reconnect stream allocation
if (oldEdge.type === 'stream') {
setNodes((nds) => nds.map((node) => {
if (node.id !== oldEdge.source || node.type !== 'funnel') return node
const data = node.data as FunnelNodeData
return {
...node,
data: {
...data,
streamAllocations: (data.streamAllocations || []).map(s =>
s.targetId === oldTargetId ? { ...s, targetId: newTargetId } : s
),
},
}
}))
return
}
setNodes((nds) => nds.map((node) => {
if (node.id !== oldEdge.source || node.type !== 'funnel') return node
const data = node.data as FunnelNodeData
@ -353,7 +426,19 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
if (node.id !== edge.source || node.type !== 'funnel') return node
const data = node.data as FunnelNodeData
if (edge.data!.edgeType === 'overflow') {
// Stream edge removal
if (edge.type === 'stream') {
return {
...node,
data: {
...data,
streamAllocations: (data.streamAllocations || []).filter(s => s.targetId !== edge.target),
},
}
}
const allocData = edge.data as AllocationEdgeData
if (allocData.edgeType === 'overflow') {
const filtered = data.overflowAllocations.filter(a => a.targetId !== edge.target)
const total = filtered.reduce((s, a) => s + a.percentage, 0)
return {
@ -564,6 +649,10 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
<div className="w-3 h-3 rounded-full bg-blue-500" />
<span className="text-slate-600">Spending (bottom)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500 border border-dashed border-green-700" />
<span className="text-slate-600">Stream (Superfluid)</span>
</div>
<div className="border-t border-slate-200 pt-1.5 mt-1.5">
<span className="text-[10px] text-slate-400">Edge width = relative flow amount</span>
</div>

View File

@ -4,7 +4,9 @@ import { useState, useCallback } from 'react'
import type { FlowNode, IntegrationConfig } from '@/lib/types'
import { SUPPORTED_CHAINS, getBalances, detectSafeChains } from '@/lib/api/safe-client'
import type { SafeBalance, DetectedChain } from '@/lib/api/safe-client'
import { safeBalancesToFunnels } from '@/lib/integrations'
import { safeBalancesToFunnels, proposalsToOutcomes } from '@/lib/integrations'
import { fetchSpace, fetchPassedProposals } from '@/lib/api/rvote-client'
import type { RVoteProposal } from '@/lib/api/rvote-client'
interface IntegrationPanelProps {
isOpen: boolean
@ -37,6 +39,7 @@ export default function IntegrationPanel({
const [rvoteError, setRvoteError] = useState('')
const [rvoteSpace, setRvoteSpace] = useState<{ name: string; memberCount: number } | null>(null)
const [rvoteProposals, setRvoteProposals] = useState<Array<{ id: string; title: string; score: number }>>([])
const [fetchedProposals, setFetchedProposals] = useState<RVoteProposal[]>([])
const handleFetchSafe = useCallback(async () => {
if (!safeAddress.match(/^0x[a-fA-F0-9]{40}$/)) {
@ -109,32 +112,14 @@ export default function IntegrationPanel({
setRvoteError('')
try {
// Fetch via proxy to avoid CORS
const spaceRes = await fetch(`/api/proxy/rvote?endpoint=space&slug=${encodeURIComponent(rvoteSlug)}`)
if (!spaceRes.ok) {
setRvoteError(spaceRes.status === 404 ? 'Space not found' : 'Failed to fetch space')
setRvoteFetching(false)
return
}
const space = await spaceRes.json()
const space = await fetchSpace(rvoteSlug)
setRvoteSpace({ name: space.name, memberCount: space._count?.members || 0 })
const proposalsRes = await fetch(
`/api/proxy/rvote?endpoint=proposals&slug=${encodeURIComponent(rvoteSlug)}&status=PASSED`
const proposals = await fetchPassedProposals(rvoteSlug)
setRvoteProposals(
proposals.map((p) => ({ id: p.id, title: p.title, score: p.score }))
)
if (proposalsRes.ok) {
const data = await proposalsRes.json()
const proposals = data.proposals || data.results || data || []
setRvoteProposals(
Array.isArray(proposals)
? proposals.map((p: { id: string; title: string; score: number }) => ({
id: p.id,
title: p.title,
score: p.score,
}))
: []
)
}
setFetchedProposals(proposals)
onIntegrationsChange({
...integrations,
@ -151,29 +136,14 @@ export default function IntegrationPanel({
}
}, [rvoteSlug, integrations, onIntegrationsChange])
const handleImportRvote = useCallback(async () => {
if (rvoteProposals.length === 0) return
// Re-fetch full proposal data for import
try {
const res = await fetch(
`/api/proxy/rvote?endpoint=proposals&slug=${encodeURIComponent(rvoteSlug)}&status=PASSED`
)
if (!res.ok) return
const data = await res.json()
const proposals = data.proposals || data.results || data || []
if (!Array.isArray(proposals)) return
const { proposalsToOutcomes } = await import('@/lib/integrations')
const nodes = proposalsToOutcomes(proposals, rvoteSlug)
if (nodes.length > 0) {
onImportNodes(nodes)
onClose()
}
} catch {
setRvoteError('Failed to import proposals')
const handleImportRvote = useCallback(() => {
if (fetchedProposals.length === 0) return
const nodes = proposalsToOutcomes(fetchedProposals, rvoteSlug)
if (nodes.length > 0) {
onImportNodes(nodes)
onClose()
}
}, [rvoteProposals, rvoteSlug, onImportNodes, onClose])
}, [fetchedProposals, rvoteSlug, onImportNodes, onClose])
const toggleChain = (chainId: number) => {
setSelectedChains((prev) =>

107
components/SplitsView.tsx Normal file
View File

@ -0,0 +1,107 @@
'use client'
import { useCallback, useState } from 'react'
import type { SplitsConfig } from '@/lib/types'
interface SplitsViewProps {
config: SplitsConfig
}
const CHAIN_NAMES: Record<number, string> = {
100: 'Gnosis',
10: 'Optimism',
1: 'Ethereum',
}
function truncateAddress(addr: string): string {
return `${addr.slice(0, 6)}...${addr.slice(-4)}`
}
const BAR_COLORS = [
'#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981',
'#f59e0b', '#ef4444', '#6366f1', '#14b8a6', '#f97316',
]
export default function SplitsView({ config }: SplitsViewProps) {
const [copied, setCopied] = useState(false)
const handleExport = useCallback(() => {
const exportData = {
type: '0xSplits',
chainId: config.chainId,
distributorFee: config.distributorFee,
recipients: config.recipients.map((r) => ({
address: r.address,
percentAllocation: r.percentage * 10000, // 0xSplits uses basis points (ppm)
})),
}
navigator.clipboard.writeText(JSON.stringify(exportData, null, 2)).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}, [config])
const totalPct = config.recipients.reduce((s, r) => s + r.percentage, 0)
const chainName = CHAIN_NAMES[config.chainId] || `Chain ${config.chainId}`
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-slate-700 uppercase tracking-wide">
0xSplits Config
</span>
<span className="text-[10px] px-1.5 py-0.5 bg-slate-100 text-slate-500 rounded">
{chainName}
</span>
</div>
<span className="text-[10px] text-slate-400">
Fee: {config.distributorFee}%
</span>
</div>
{/* Combined percentage bar */}
<div className="h-3 bg-slate-100 rounded-full overflow-hidden flex">
{config.recipients.map((r, i) => (
<div
key={r.address}
className="h-full transition-all"
style={{
width: `${(r.percentage / totalPct) * 100}%`,
backgroundColor: BAR_COLORS[i % BAR_COLORS.length],
}}
/>
))}
</div>
{/* Recipient list */}
<div className="space-y-1.5">
{config.recipients.map((r, i) => (
<div key={r.address} className="flex items-center gap-2 text-xs">
<div
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
style={{ backgroundColor: BAR_COLORS[i % BAR_COLORS.length] }}
/>
<span className="font-mono text-slate-600 flex-1 truncate" title={r.address}>
{r.label || truncateAddress(r.address)}
</span>
<span className="font-mono font-medium text-slate-800">{r.percentage}%</span>
</div>
))}
</div>
{totalPct !== 100 && (
<p className="text-[10px] text-amber-600">
Total is {totalPct}% (should be 100%)
</p>
)}
<button
onClick={handleExport}
className="w-full px-3 py-1.5 rounded-lg text-xs font-medium border border-slate-300 text-slate-600 hover:bg-slate-50 transition-all"
>
{copied ? 'Copied to clipboard!' : 'Export Config JSON'}
</button>
</div>
)
}

View File

@ -7,7 +7,7 @@ import {
BaseEdge,
type EdgeProps,
} from '@xyflow/react'
import type { FlowEdgeData } from '@/lib/types'
import type { AllocationEdgeData } from '@/lib/types'
export default function AllocationEdge({
id,
@ -21,7 +21,7 @@ export default function AllocationEdge({
style,
markerEnd,
}: EdgeProps) {
const edgeData = data as FlowEdgeData | undefined
const edgeData = data as AllocationEdgeData | undefined
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,

View File

@ -0,0 +1,125 @@
'use client'
import {
getSmoothStepPath,
EdgeLabelRenderer,
BaseEdge,
type EdgeProps,
} from '@xyflow/react'
import type { StreamEdgeData } from '@/lib/types'
const STATUS_COLORS: Record<string, { stroke: string; bg: string; text: string; label: string }> = {
planned: { stroke: '#22c55e', bg: 'rgba(34,197,94,0.12)', text: '#16a34a', label: 'Planned' },
active: { stroke: '#3b82f6', bg: 'rgba(59,130,246,0.12)', text: '#2563eb', label: 'Active' },
paused: { stroke: '#f97316', bg: 'rgba(249,115,22,0.12)', text: '#ea580c', label: 'Paused' },
}
export default function StreamEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
markerEnd,
}: EdgeProps) {
const edgeData = data as StreamEdgeData | undefined
const status = edgeData?.status ?? 'planned'
const flowRate = edgeData?.flowRate ?? 0
const tokenSymbol = edgeData?.tokenSymbol ?? 'DAIx'
const colors = STATUS_COLORS[status] || STATUS_COLORS.planned
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
})
// Animated dashed line to simulate flowing tokens
const isPaused = status === 'paused'
return (
<>
{/* Background glow path */}
<path
d={edgePath}
fill="none"
stroke={colors.stroke}
strokeWidth={8}
strokeOpacity={0.15}
/>
{/* Main path with animated dash */}
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
stroke: colors.stroke,
strokeWidth: 3,
strokeDasharray: '8 4',
animation: isPaused ? 'none' : 'streamFlow 1s linear infinite',
opacity: isPaused ? 0.5 : 0.9,
}}
/>
{/* Inject animation keyframes */}
<foreignObject width={0} height={0}>
<style>{`
@keyframes streamFlow {
to { stroke-dashoffset: -12; }
}
`}</style>
</foreignObject>
<EdgeLabelRenderer>
<div
className="nodrag nopan"
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
}}
>
{/* Flow rate label */}
<span
style={{
fontSize: 10,
fontWeight: 600,
fontFamily: 'monospace',
color: colors.text,
background: 'rgba(255,255,255,0.95)',
padding: '2px 6px',
borderRadius: 4,
border: `1px solid ${colors.stroke}`,
whiteSpace: 'nowrap',
}}
>
{flowRate.toLocaleString()} {tokenSymbol}/mo
</span>
{/* Status pill */}
<span
style={{
fontSize: 8,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: colors.text,
background: colors.bg,
padding: '1px 5px',
borderRadius: 6,
}}
>
{colors.label}
</span>
</div>
</EdgeLabelRenderer>
</>
)
}

View File

@ -4,6 +4,7 @@ import { memo, useState, useCallback, useRef, useEffect } from 'react'
import { Handle, Position, useReactFlow } from '@xyflow/react'
import type { NodeProps } from '@xyflow/react'
import type { FunnelNodeData, OutcomeNodeData } from '@/lib/types'
import SplitsView from '../SplitsView'
const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
@ -597,6 +598,15 @@ function FunnelNode({ data, selected, id }: NodeProps) {
id="spending-out"
className="!w-4 !h-4 !bg-blue-500 !border-2 !border-white !-bottom-2"
/>
{/* Stream handle (Superfluid) - right side, lower */}
<Handle
type="source"
position={Position.Right}
id="stream-out"
className="!w-3.5 !h-3.5 !bg-green-500 !border-2 !border-white !rounded-full"
style={{ top: '65%' }}
/>
</div>
{isEditing && (
@ -826,6 +836,36 @@ function FunnelNode({ data, selected, id }: NodeProps) {
</div>
</div>
{nodeData.splitsConfig && (
<div className="mt-4 p-4 bg-slate-50 rounded-xl">
<SplitsView config={nodeData.splitsConfig} />
</div>
)}
{nodeData.streamAllocations && nodeData.streamAllocations.length > 0 && (
<div className="mt-4 p-4 bg-green-50 rounded-xl">
<span className="text-xs font-semibold text-green-700 uppercase tracking-wide block mb-2">
Superfluid Streams
</span>
<div className="space-y-1.5">
{nodeData.streamAllocations.map((stream, i) => (
<div key={i} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${
stream.status === 'active' ? 'bg-blue-500' :
stream.status === 'paused' ? 'bg-orange-500' : 'bg-green-500'
}`} />
<span className="text-slate-600">{stream.targetId}</span>
</div>
<span className="font-mono text-green-700">
{stream.flowRate.toLocaleString()} {stream.tokenSymbol}/mo
</span>
</div>
))}
</div>
</div>
)}
<p className="text-center text-[10px] text-slate-400 mt-4">
Drag pie slices to adjust &bull; Click + to add new items
</p>

37
lib/api/rvote-client.ts Normal file
View File

@ -0,0 +1,37 @@
export interface RVoteSpace {
name: string
slug: string
_count?: { members: number }
}
export interface RVoteProposal {
id: string
title: string
description?: string
score: number
status: string
createdAt?: string
}
const PROXY_BASE = '/api/proxy/rvote'
export async function fetchSpace(slug: string): Promise<RVoteSpace> {
const res = await fetch(`${PROXY_BASE}?endpoint=space&slug=${encodeURIComponent(slug)}`)
if (!res.ok) {
if (res.status === 404) throw new Error('Space not found')
throw new Error(`Failed to fetch space: ${res.status}`)
}
return res.json()
}
export async function fetchPassedProposals(slug: string): Promise<RVoteProposal[]> {
const res = await fetch(
`${PROXY_BASE}?endpoint=proposals&slug=${encodeURIComponent(slug)}&status=PASSED`
)
if (!res.ok) {
throw new Error(`Failed to fetch proposals: ${res.status}`)
}
const data = await res.json()
const proposals = data.proposals || data.results || data || []
return Array.isArray(proposals) ? proposals : []
}

View File

@ -57,11 +57,11 @@ export function safeBalancesToFunnels(
export interface RVoteProposal {
id: string
title: string
description: string
description?: string
status: string
score: number
author?: { id: string; name: string | null }
createdAt: string
createdAt?: string
_count?: { votes: number; finalVotes: number }
}

View File

@ -105,7 +105,7 @@ export interface OutcomeNodeData {
export type FlowNode = Node<FunnelNodeData | OutcomeNodeData>
export interface FlowEdgeData {
export interface AllocationEdgeData {
allocation: number // percentage 0-100
color: string
edgeType: 'overflow' | 'spending' // overflow = sideways, spending = downward
@ -116,6 +116,17 @@ export interface FlowEdgeData {
[key: string]: unknown
}
export interface StreamEdgeData {
flowRate: number
tokenSymbol: string
status: 'planned' | 'active' | 'paused'
sourceId: string
targetId: string
[key: string]: unknown
}
export type FlowEdgeData = AllocationEdgeData | StreamEdgeData
export type FlowEdge = Edge<FlowEdgeData>
// Serializable space config (no xyflow internals)