From 48769281c6fcc2066fa73331611ec84be85f9daa Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 19:20:55 -0800 Subject: [PATCH] feat: animated edges, rich node modals, hover effects for rFunds Diagram Port standalone rfunds-online UX polish to rSpace integration: - Animated flowing edges with glow paths and CSS-animated dashes - Wider Sankey-proportional stroke widths (12/10/8px) - Rich outcome modal with phase accordion, task checkboxes, funding progress - Rich source modal with type picker grid and type-specific config - Node hover tooltips showing key stats + connected edge highlighting - Sufficiency glow pulse on funnel status text Co-Authored-By: Claude Opus 4.6 --- modules/rfunds/components/folk-funds-app.ts | 411 +++++++++++++++++++- modules/rfunds/components/funds.css | 90 +++++ 2 files changed, 493 insertions(+), 8 deletions(-) diff --git a/modules/rfunds/components/folk-funds-app.ts b/modules/rfunds/components/folk-funds-app.ts index cf9a7a9..80820f6 100644 --- a/modules/rfunds/components/folk-funds-app.ts +++ b/modules/rfunds/components/folk-funds-app.ts @@ -590,6 +590,7 @@ class FolkFundsApp extends HTMLElement { + `; } @@ -815,7 +816,33 @@ class FolkFundsApp extends HTMLElement { const group = (e.target as Element).closest(".flow-node") as SVGGElement | null; if (!group) return; const nodeId = group.dataset.nodeId; - if (nodeId) this.openEditor(nodeId); + if (!nodeId) return; + const node = this.nodes.find((n) => n.id === nodeId); + if (!node) return; + if (node.type === "outcome") this.openOutcomeModal(nodeId); + else if (node.type === "source") this.openSourceModal(nodeId); + else this.openEditor(nodeId); + }); + + // Hover: tooltip + edge highlighting + let hoveredNodeId: string | null = null; + nodeLayer.addEventListener("mouseover", (e: MouseEvent) => { + const group = (e.target as Element).closest(".flow-node") as SVGGElement | null; + if (!group) return; + const nodeId = group.dataset.nodeId; + if (nodeId && nodeId !== hoveredNodeId) { + hoveredNodeId = nodeId; + this.showNodeTooltip(nodeId, e); + this.highlightNodeEdges(nodeId); + } + }); + nodeLayer.addEventListener("mouseout", (e: MouseEvent) => { + const related = (e.relatedTarget as Element | null)?.closest?.(".flow-node"); + if (!related) { + hoveredNodeId = null; + this.hideNodeTooltip(); + this.unhighlightEdges(); + } }); } @@ -857,6 +884,7 @@ class FolkFundsApp extends HTMLElement { if (e.key === "Escape") { if (this.wiringActive) { this.cancelWiring(); return; } + this.closeModal(); this.closeEditor(); } else if (e.key === " ") { e.preventDefault(); this.toggleSimulation(); } @@ -936,7 +964,7 @@ class FolkFundsApp extends HTMLElement { ${this.esc(d.label)} - ${statusLabel} + ${statusLabel} $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} @@ -1002,7 +1030,7 @@ class FolkFundsApp extends HTMLElement { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; const to = this.getPortPosition(target, "inflow"); - const strokeW = Math.max(2, (d.flowRate / maxFlow) * (alloc.percentage / 100) * 8); + const strokeW = Math.max(2, (d.flowRate / maxFlow) * (alloc.percentage / 100) * 12); html += this.renderEdgePath( from.x, from.y, to.x, to.y, alloc.color || "#10b981", strokeW, false, @@ -1018,7 +1046,7 @@ class FolkFundsApp extends HTMLElement { if (!target) continue; const from = this.getPortPosition(n, "overflow"); const to = this.getPortPosition(target, "inflow"); - const strokeW = Math.max(1.5, (alloc.percentage / 100) * 6); + const strokeW = Math.max(1.5, (alloc.percentage / 100) * 10); html += this.renderEdgePath( from.x, from.y, to.x, to.y, alloc.color || "#f59e0b", strokeW, true, @@ -1031,7 +1059,7 @@ class FolkFundsApp extends HTMLElement { if (!target) continue; const from = this.getPortPosition(n, "spending"); const to = this.getPortPosition(target, "inflow"); - const strokeW = Math.max(1.5, (alloc.percentage / 100) * 5); + const strokeW = Math.max(1.5, (alloc.percentage / 100) * 8); html += this.renderEdgePath( from.x, from.y, to.x, to.y, alloc.color || "#8b5cf6", strokeW, false, @@ -1048,7 +1076,7 @@ class FolkFundsApp extends HTMLElement { if (!target) continue; const from = this.getPortPosition(n, "overflow"); const to = this.getPortPosition(target, "inflow"); - const strokeW = Math.max(1.5, (alloc.percentage / 100) * 5); + const strokeW = Math.max(1.5, (alloc.percentage / 100) * 8); html += this.renderEdgePath( from.x, from.y, to.x, to.y, alloc.color || "#f59e0b", strokeW, true, @@ -1069,9 +1097,11 @@ class FolkFundsApp extends HTMLElement { const cy2 = y1 + (y2 - y1) * 0.6; const midX = (x1 + x2) / 2; const midY = (y1 + y2) / 2; - const dash = dashed ? ' stroke-dasharray="6 3"' : ""; + const d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`; + const animClass = dashed ? "edge-path-overflow" : "edge-path-animated"; return ` - + + ${pct}% @@ -1577,6 +1607,371 @@ class FolkFundsApp extends HTMLElement { }); } + // ─── Node hover tooltip ────────────────────────────── + + private showNodeTooltip(nodeId: string, e: MouseEvent) { + const tooltip = this.shadow.getElementById("node-tooltip"); + const container = this.shadow.getElementById("canvas-container"); + if (!tooltip || !container) return; + const node = this.nodes.find((n) => n.id === nodeId); + if (!node) return; + + let html = `
${this.esc((node.data as any).label)}
`; + if (node.type === "source") { + const d = node.data as SourceNodeData; + html += `
$${d.flowRate.toLocaleString()}/mo · ${d.sourceType}
`; + } else if (node.type === "funnel") { + const d = node.data as FunnelNodeData; + const suf = computeSufficiencyState(d); + html += `
$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.sufficientThreshold ?? d.maxThreshold).toLocaleString()}
`; + html += `
${suf}
`; + } else { + const d = node.data as OutcomeNodeData; + const pct = d.fundingTarget > 0 ? Math.round((d.fundingReceived / d.fundingTarget) * 100) : 0; + html += `
$${Math.floor(d.fundingReceived).toLocaleString()} / $${Math.floor(d.fundingTarget).toLocaleString()} (${pct}%)
`; + html += `
${d.status}
`; + } + + tooltip.innerHTML = html; + tooltip.style.display = "block"; + const rect = container.getBoundingClientRect(); + tooltip.style.left = `${e.clientX - rect.left + 16}px`; + tooltip.style.top = `${e.clientY - rect.top - 8}px`; + } + + private hideNodeTooltip() { + const tooltip = this.shadow.getElementById("node-tooltip"); + if (tooltip) tooltip.style.display = "none"; + } + + private highlightNodeEdges(nodeId: string) { + const edgeLayer = this.shadow.getElementById("edge-layer"); + if (!edgeLayer) return; + edgeLayer.querySelectorAll(".edge-group").forEach((g) => { + const el = g as SVGGElement; + const isConnected = el.dataset.from === nodeId || el.dataset.to === nodeId; + el.classList.toggle("edge-group--highlight", isConnected); + }); + } + + private unhighlightEdges() { + const edgeLayer = this.shadow.getElementById("edge-layer"); + if (!edgeLayer) return; + edgeLayer.querySelectorAll(".edge-group--highlight").forEach((g) => { + g.classList.remove("edge-group--highlight"); + }); + } + + // ─── Node detail modals ────────────────────────────── + + private closeModal() { + const m = this.shadow.getElementById("funds-modal"); + if (m) m.remove(); + } + + private openOutcomeModal(nodeId: string) { + this.closeModal(); + const node = this.nodes.find((n) => n.id === nodeId); + if (!node || node.type !== "outcome") return; + const d = node.data as OutcomeNodeData; + + const fillPct = d.fundingTarget > 0 ? Math.min(100, (d.fundingReceived / d.fundingTarget) * 100) : 0; + const statusColor = d.status === "completed" ? "#10b981" + : d.status === "blocked" ? "#ef4444" + : d.status === "in-progress" ? "#3b82f6" : "#64748b"; + const statusLabel = d.status === "completed" ? "Completed" + : d.status === "blocked" ? "Blocked" + : d.status === "in-progress" ? "In Progress" : "Not Started"; + + let phasesHtml = ""; + if (d.phases && d.phases.length > 0) { + phasesHtml += `
`; + for (const p of d.phases) { + const unlocked = d.fundingReceived >= p.fundingThreshold; + phasesHtml += `
`; + } + phasesHtml += `
`; + + for (let i = 0; i < d.phases.length; i++) { + const p = d.phases[i]; + const unlocked = d.fundingReceived >= p.fundingThreshold; + const completedTasks = p.tasks.filter((t) => t.completed).length; + const phasePct = p.fundingThreshold > 0 ? Math.min(100, Math.round((d.fundingReceived / p.fundingThreshold) * 100)) : 0; + + phasesHtml += `
+
+ ${unlocked ? "🔓" : "🔒"} + ${this.esc(p.name)} + ${completedTasks}/${p.tasks.length} + $${p.fundingThreshold.toLocaleString()} + +
+ +
`; + } + + phasesHtml += ``; + } + + const backdrop = document.createElement("div"); + backdrop.className = "funds-modal-backdrop"; + backdrop.id = "funds-modal"; + backdrop.innerHTML = `
+
+
+ ${statusLabel} + ${this.esc(d.label)} +
+ +
+ ${d.description ? `
${this.esc(d.description)}
` : ""} +
+
$${Math.floor(d.fundingReceived).toLocaleString()}
+
of $${Math.floor(d.fundingTarget).toLocaleString()} (${Math.round(fillPct)}%)
+
+
+
+
+ ${d.phases && d.phases.length > 0 ? `
+
Phases
+ ${phasesHtml} +
` : ""} +
`; + + this.shadow.appendChild(backdrop); + this.attachOutcomeModalListeners(backdrop, nodeId); + } + + private attachOutcomeModalListeners(backdrop: HTMLElement, nodeId: string) { + const node = this.nodes.find((n) => n.id === nodeId); + if (!node) return; + const d = node.data as OutcomeNodeData; + + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) this.closeModal(); + }); + backdrop.querySelector('[data-modal-action="close"]')?.addEventListener("click", () => this.closeModal()); + + // Phase accordion toggle + backdrop.querySelectorAll(".phase-header").forEach((header) => { + header.addEventListener("click", () => { + const idx = (header as HTMLElement).dataset.phaseIdx; + const content = backdrop.querySelector(`[data-phase-content="${idx}"]`) as HTMLElement | null; + const chevron = backdrop.querySelector(`[data-phase-chevron="${idx}"]`) as HTMLElement | null; + if (content) { + const isOpen = content.style.display !== "none"; + content.style.display = isOpen ? "none" : "block"; + if (chevron) chevron.style.transform = isOpen ? "rotate(0deg)" : "rotate(90deg)"; + } + }); + }); + + // Task checkbox toggle + backdrop.querySelectorAll('input[type="checkbox"][data-phase]').forEach((cb) => { + cb.addEventListener("change", () => { + const phaseIdx = parseInt((cb as HTMLElement).dataset.phase!, 10); + const taskIdx = parseInt((cb as HTMLElement).dataset.task!, 10); + if (d.phases && d.phases[phaseIdx] && d.phases[phaseIdx].tasks[taskIdx]) { + d.phases[phaseIdx].tasks[taskIdx].completed = (cb as HTMLInputElement).checked; + const taskRow = (cb as HTMLElement).closest(".phase-task"); + if (taskRow) taskRow.classList.toggle("phase-task--done", (cb as HTMLInputElement).checked); + this.drawCanvasContent(); + } + }); + }); + + // Add task + backdrop.querySelectorAll("[data-add-task]").forEach((btn) => { + btn.addEventListener("click", () => { + const phaseIdx = parseInt((btn as HTMLElement).dataset.addTask!, 10); + if (d.phases && d.phases[phaseIdx]) { + const taskLabel = prompt("Task name:"); + if (taskLabel) { + d.phases[phaseIdx].tasks.push({ label: taskLabel, completed: false }); + this.openOutcomeModal(nodeId); + this.drawCanvasContent(); + } + } + }); + }); + + // Add phase + backdrop.querySelector('[data-action="add-phase"]')?.addEventListener("click", () => { + const name = prompt("Phase name:"); + if (name) { + const threshold = parseFloat(prompt("Funding threshold ($):") || "0") || 0; + if (!d.phases) d.phases = []; + d.phases.push({ name, fundingThreshold: threshold, tasks: [] }); + this.openOutcomeModal(nodeId); + this.drawCanvasContent(); + } + }); + } + + private openSourceModal(nodeId: string) { + this.closeModal(); + const node = this.nodes.find((n) => n.id === nodeId); + if (!node || node.type !== "source") return; + const d = node.data as SourceNodeData; + + const icons: Record = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" }; + const labels: Record = { card: "Card", safe_wallet: "Safe / Wallet", ridentity: "rIdentity", unconfigured: "Configure" }; + + let configHtml = ""; + if (d.sourceType === "card") { + configHtml = `
+
+ + +
+ +
`; + } else if (d.sourceType === "safe_wallet") { + configHtml = `
+
+ + +
+
+ + +
+
+ + +
+
`; + } else if (d.sourceType === "ridentity") { + configHtml = `
+
👤
+
${isAuthenticated() ? "Connected" : "Not connected"}
+ ${!isAuthenticated() ? `` : `
✅ ${this.esc(getUsername() || "Connected")}
`} +
`; + } + + const backdrop = document.createElement("div"); + backdrop.className = "funds-modal-backdrop"; + backdrop.id = "funds-modal"; + backdrop.innerHTML = `
+
+
+ ${icons[d.sourceType] || "💰"} + ${this.esc(d.label)} +
+ +
+
+
Source Type
+
+ ${["card", "safe_wallet", "ridentity"].map((t) => ` + + `).join("")} +
+
+
+ + +
+
+ + +
+
${configHtml}
+
+ + +
+
`; + + this.shadow.appendChild(backdrop); + this.attachSourceModalListeners(backdrop, nodeId); + } + + private attachSourceModalListeners(backdrop: HTMLElement, nodeId: string) { + const node = this.nodes.find((n) => n.id === nodeId); + if (!node) return; + const d = node.data as SourceNodeData; + + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) this.closeModal(); + }); + backdrop.querySelectorAll('[data-modal-action="close"]').forEach((btn) => { + btn.addEventListener("click", () => this.closeModal()); + }); + + // Source type picker + backdrop.querySelectorAll("[data-source-type]").forEach((btn) => { + btn.addEventListener("click", () => { + d.sourceType = (btn as HTMLElement).dataset.sourceType as SourceNodeData["sourceType"]; + this.openSourceModal(nodeId); + this.drawCanvasContent(); + }); + }); + + // Field changes (live) + backdrop.querySelectorAll(".editor-input[data-modal-field], .editor-select[data-modal-field]").forEach((input) => { + input.addEventListener("change", () => { + const field = (input as HTMLElement).dataset.modalField!; + const val = (input as HTMLInputElement).value; + const numFields = ["flowRate", "chainId"]; + (d as any)[field] = numFields.includes(field) ? parseFloat(val) || 0 : val; + this.drawCanvasContent(); + }); + }); + + // Save + backdrop.querySelector('[data-modal-action="save"]')?.addEventListener("click", () => { + backdrop.querySelectorAll(".editor-input[data-modal-field], .editor-select[data-modal-field]").forEach((input) => { + const field = (input as HTMLElement).dataset.modalField!; + const val = (input as HTMLInputElement).value; + const numFields = ["flowRate", "chainId"]; + (d as any)[field] = numFields.includes(field) ? parseFloat(val) || 0 : val; + }); + this.drawCanvasContent(); + this.closeModal(); + }); + + // Fund with card + backdrop.querySelector('[data-action="fund-with-card"]')?.addEventListener("click", () => { + const flowId = this.flowId || this.getAttribute("flow-id") || ""; + if (!d.walletAddress) { + alert("Configure a wallet address first (use rIdentity passkey or enter manually)"); + return; + } + this.openTransakWidget(flowId, d.walletAddress); + }); + + // Connect with EncryptID + backdrop.querySelector('[data-action="connect-ridentity"]')?.addEventListener("click", () => { + window.location.href = "/auth/login?redirect=" + encodeURIComponent(window.location.pathname); + }); + } + // ─── Node CRUD ──────────────────────────────────────── private addNode(type: "source" | "funnel" | "outcome") { diff --git a/modules/rfunds/components/funds.css b/modules/rfunds/components/funds.css index 2800e1f..2617e5c 100644 --- a/modules/rfunds/components/funds.css +++ b/modules/rfunds/components/funds.css @@ -326,6 +326,96 @@ to { stroke-dashoffset: -12; } } +/* ── Edge flow animation ──────────────────────────────── */ +@keyframes streamFlow { to { stroke-dashoffset: -24; } } +.edge-path-animated { stroke-dasharray: 8 4; animation: streamFlow 1s linear infinite; } +.edge-path-overflow { stroke-dasharray: 6 3; animation: streamFlow 0.7s linear infinite; } +.edge-glow { pointer-events: none; } +.edge-group--highlight path:not(.edge-glow) { stroke-opacity: 1 !important; filter: brightness(1.4); } +.edge-group--highlight .edge-glow { stroke-opacity: 0.25 !important; } + +/* ── Node detail modals ──────────────────────────────── */ +.funds-modal-backdrop { + position: fixed; inset: 0; z-index: 50; + background: rgba(0,0,0,0.6); display: flex; + align-items: center; justify-content: center; + animation: modalFadeIn 0.15s ease-out; +} +@keyframes modalFadeIn { from { opacity: 0; } to { opacity: 1; } } +.funds-modal { + background: #1e293b; border-radius: 16px; padding: 24px; + width: 440px; max-height: 85vh; overflow-y: auto; + border: 1px solid #334155; box-shadow: 0 20px 60px rgba(0,0,0,0.5); + animation: modalSlideIn 0.2s ease-out; +} +@keyframes modalSlideIn { from { transform: translateY(12px); opacity: 0; } to { transform: none; opacity: 1; } } +.funds-modal::-webkit-scrollbar { width: 6px; } +.funds-modal::-webkit-scrollbar-track { background: transparent; } +.funds-modal::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; } +.funds-modal__header { + display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; +} +.funds-modal__close { + background: none; border: none; color: #94a3b8; font-size: 24px; cursor: pointer; + padding: 2px 8px; border-radius: 4px; transition: color 0.15s; +} +.funds-modal__close:hover { color: #e2e8f0; } +.funds-modal__progress-bar { + height: 8px; background: #334155; border-radius: 4px; overflow: hidden; margin-top: 8px; +} +.funds-modal__progress-fill { height: 100%; border-radius: 4px; transition: width 0.3s; } + +/* Phase accordion */ +.phase-tier-bar { display: flex; gap: 1px; height: 8px; border-radius: 4px; overflow: hidden; margin-bottom: 16px; } +.phase-tier-segment { flex: 1; transition: background 0.3s; } +.phase-card { border: 1px solid #334155; border-radius: 10px; overflow: hidden; margin-bottom: 8px; } +.phase-card--locked { opacity: 0.5; } +.phase-header { + padding: 10px 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; + background: #0f172a; transition: background 0.15s; +} +.phase-header:hover { background: #1e293b; } +.phase-content { padding: 8px 14px 14px; border-top: 1px solid #334155; } +.phase-task { + display: flex; align-items: center; gap: 8px; font-size: 13px; color: #94a3b8; padding: 4px 0; +} +.phase-task input[type="checkbox"] { accent-color: #10b981; cursor: pointer; } +.phase-task--done { color: #64748b; text-decoration: line-through; } +.phase-add-btn { + display: flex; align-items: center; gap: 4px; font-size: 12px; color: #64748b; + background: none; border: 1px dashed #334155; border-radius: 6px; + padding: 4px 10px; cursor: pointer; margin-top: 6px; transition: all 0.15s; +} +.phase-add-btn:hover { color: #94a3b8; border-color: #475569; } + +/* Source type picker */ +.source-type-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; } +.source-type-btn { + display: flex; flex-direction: column; align-items: center; gap: 6px; + padding: 14px 8px; border-radius: 10px; border: 2px solid #334155; + background: #0f172a; color: #94a3b8; cursor: pointer; transition: all 0.15s; + font-size: 12px; font-weight: 500; +} +.source-type-btn:hover { border-color: #475569; background: #1e293b; } +.source-type-btn--active { border-color: #10b981; background: #064e3b; color: #6ee7b7; } + +/* Node hover tooltip */ +.funds-node-tooltip { + position: absolute; z-index: 30; pointer-events: none; + background: rgba(15,23,42,0.95); border: 1px solid #475569; border-radius: 8px; + padding: 8px 12px; font-size: 12px; color: #e2e8f0; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); white-space: nowrap; +} +.funds-node-tooltip__label { font-weight: 600; margin-bottom: 2px; } +.funds-node-tooltip__stat { color: #94a3b8; font-size: 11px; } + +/* Sufficiency glow on funnel status text */ +@keyframes sufficiencyPulse { + 0%, 100% { fill-opacity: 1; } + 50% { fill-opacity: 0.6; } +} +.sufficiency-glow { animation: sufficiencyPulse 2s ease-in-out infinite; } + /* ── Mobile responsive ──────────────────────────────── */ @media (max-width: 768px) { .funds-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; }