feat(rflows): faucet-shaped source nodes with purchase modal + MetaMask

Replace flat source cards with pipe/valve/spigot faucet SVG. Click opens a
centered purchase modal (label, amount, payment method) instead of the cramped
side panel. Adds MetaMask as a new payment option alongside Card and rIdentity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-10 11:56:13 -07:00
parent 5ded81046c
commit 9a7548e5ca
3 changed files with 294 additions and 34 deletions

View File

@ -1087,6 +1087,58 @@
font-size: 13px;
}
/* ── Faucet source node ──────────────────────────────── */
.faucet-pipe { transition: stroke 0.2s; }
.faucet-valve { transition: fill 0.2s; cursor: pointer; }
.faucet-valve:hover { filter: brightness(1.15); }
.faucet-handle { transition: transform 0.3s ease; }
.faucet-stream { opacity: 0.45; }
.faucet-spigot { transition: fill 0.2s; }
@keyframes faucet-drip {
0%, 100% { opacity: 0.45; }
50% { opacity: 0.25; }
}
.faucet-stream { animation: faucet-drip 2.5s ease-in-out infinite; }
/* ── Source Purchase Modal ───────────────────────────── */
.source-modal {
position: fixed; inset: 0; z-index: 99999;
display: flex; align-items: center; justify-content: center;
}
.spm-backdrop {
position: absolute; inset: 0;
background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(2px);
}
.spm-card {
position: relative; z-index: 1;
background: var(--rs-bg-surface, #1e293b);
border: 1px solid var(--rflows-modal-border, #334155);
border-radius: 16px; padding: 28px;
width: 440px; max-width: 92vw;
max-height: 85vh; overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.spm-method-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
margin-bottom: 4px;
}
.spm-method-btn {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 12px 8px; border: 2px solid var(--rs-border-strong, #334155);
border-radius: 10px; background: none; cursor: pointer;
color: var(--rs-text-secondary, #94a3b8); font-size: 12px; font-weight: 500;
transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.spm-method-btn:hover {
border-color: var(--rs-text-muted, #64748b);
background: var(--rs-bg-surface-raised, #334155);
}
.spm-method-btn--active {
border-color: #10b981; background: rgba(16, 185, 129, 0.1);
color: var(--rs-text-primary, #e2e8f0);
}
/* ── Mobile responsive ──────────────────────────────── */
@media (max-width: 768px) {
.flows-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; }

View File

@ -115,6 +115,9 @@ class FolkFlowsApp extends HTMLElement {
private draggingEdgeKey: string | null = null;
private edgeDragPointerId: number | null = null;
// Source purchase modal state
private sourceModalNodeId: string | null = null;
// Inline config panel state
private inlineEditNodeId: string | null = null;
private inlineConfigTab: "config" | "analytics" | "allocations" = "config";
@ -930,6 +933,11 @@ class FolkFlowsApp extends HTMLElement {
<marker id="arrowhead-overflow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
<path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--rflows-edge-overflow)"/>
</marker>
<linearGradient id="faucet-pipe-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#b0b8c4"/>
<stop offset="40%" stop-color="#8892a0"/>
<stop offset="100%" stop-color="#626d7d"/>
</linearGradient>
</defs>
<g id="canvas-transform">
<g id="edge-layer"></g>
@ -1061,10 +1069,7 @@ class FolkFlowsApp extends HTMLElement {
private getNodeSize(n: FlowNode): { w: number; h: number } {
if (n.type === "source") {
const d = n.data as SourceNodeData;
const baseW = 180;
const w = Math.round(baseW + Math.min(120, Math.sqrt(d.flowRate / 100) * 20));
return { w, h: 120 };
return { w: 200, h: 160 };
}
if (n.type === "funnel") {
const d = n.data as FunnelNodeData;
@ -1701,39 +1706,68 @@ class FolkFlowsApp extends HTMLElement {
const d = n.data as SourceNodeData;
const s = this.getNodeSize(n);
const x = n.position.x, y = n.position.y, w = s.w, h = s.h;
const icons: Record<string, string> = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" };
const icon = icons[d.sourceType] || "\u{1F4B0}";
// Allocation bar segments as inline HTML
let allocBarHtml = "";
// Valve color encodes sourceType
const valveColors: Record<string, string> = { card: "#3b82f6", safe_wallet: "#10b981", ridentity: "#7c3aed", metamask: "#f6851b", unconfigured: "#64748b" };
const valveColor = valveColors[d.sourceType] || "#64748b";
const isConfigured = d.sourceType !== "unconfigured";
// Pipe header dimensions
const pipeH = 22;
const pipeY = 0;
const pipeRx = 6;
// Valve body
const valveR = 28;
const valveCx = w / 2;
const valveCy = pipeY + pipeH + valveR + 4;
// Valve handle rotation: 45° configured, 90° unconfigured
const handleAngle = isConfigured ? 45 : 90;
// Spigot: trapezoid below valve
const spigotTop = valveCy + valveR + 2;
const spigotTopW = 24;
const spigotBotW = 14;
const spigotH = 20;
const spigotPath = `M ${valveCx - spigotTopW / 2},${spigotTop} L ${valveCx + spigotTopW / 2},${spigotTop} L ${valveCx + spigotBotW / 2},${spigotTop + spigotH} L ${valveCx - spigotBotW / 2},${spigotTop + spigotH} Z`;
// Flow stream at bottom — width proportional to flowRate
const streamMaxW = w - 40;
const streamW = Math.round(8 + Math.min(streamMaxW - 8, Math.sqrt(d.flowRate / 100) * (streamMaxW / 6)));
const streamY = spigotTop + spigotH;
const streamH = h - streamY;
// Amount text
const amountY = valveCy + valveR + spigotH + 8;
// Allocation bar as SVG rects
let allocBar = "";
if (d.targetAllocations && d.targetAllocations.length > 0) {
const segs = d.targetAllocations.map(a =>
`<div style="flex:${a.percentage};height:3px;background:${a.color};border-radius:1px;opacity:0.8"></div>`
).join("");
allocBarHtml = `<div style="display:flex;gap:1px;margin-top:6px;padding:0 8px">${segs}</div>`;
const barY = h - 10;
const barW = w - 40;
const barX = 20;
let cx = barX;
allocBar = d.targetAllocations.map(a => {
const segW = (a.percentage / 100) * barW;
const rect = `<rect x="${cx}" y="${barY}" width="${segW}" height="3" rx="1" fill="${a.color}" opacity="0.85"/>`;
cx += segW + 1;
return rect;
}).join("");
}
// Flow-width bar: visual river-width proportional to flowRate
const flowBarMaxW = w - 24;
const flowBarW = Math.round(12 + Math.min(flowBarMaxW - 12, Math.sqrt(d.flowRate / 100) * (flowBarMaxW / 6)));
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="12" fill="white" stroke="${selected ? "var(--rflows-selected)" : "#6ee7b7"}" stroke-width="${selected ? 3 : 2}"/>
<foreignObject x="0" y="0" width="${w}" height="${h}">
<div xmlns="http://www.w3.org/1999/xhtml" class="node-card source-card ${selected ? "selected" : ""}">
<div class="card-header" style="background:linear-gradient(to right,#ecfdf5,#f0fdfa);padding:8px 12px;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:6px">
<span style="font-size:16px">${icon}</span>
<span style="font-size:13px;font-weight:600;color:#1e293b">${this.esc(d.label)}</span>
</div>
</div>
<div style="padding:8px 12px;text-align:center">
<div style="font-size:20px;font-weight:700;color:#059669;font-family:ui-monospace,monospace">$${d.flowRate.toLocaleString()}<span style="font-size:12px;font-weight:400;color:#64748b">/mo</span></div>
</div>
${allocBarHtml}
</div>
</foreignObject>
<rect x="${(w - flowBarW) / 2}" y="${h - 6}" width="${flowBarW}" height="4" rx="2" style="fill:#10b981;opacity:0.5"/>
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="8" fill="transparent" stroke="none"/>
<rect class="faucet-pipe" x="0" y="${pipeY}" width="${w}" height="${pipeH}" rx="${pipeRx}" fill="url(#faucet-pipe-grad)" stroke="${selected ? "var(--rflows-selected)" : "#475569"}" stroke-width="${selected ? 2 : 1}"/>
<text x="${w / 2}" y="${pipeY + pipeH / 2 + 1}" text-anchor="middle" dominant-baseline="central" fill="white" font-size="11" font-weight="600" pointer-events="none">${this.esc(d.label)}</text>
<circle class="faucet-valve" cx="${valveCx}" cy="${valveCy}" r="${valveR}" fill="${valveColor}" stroke="${selected ? "var(--rflows-selected)" : "#1e293b"}" stroke-width="${selected ? 2.5 : 1.5}" style="cursor:pointer"/>
<g transform="rotate(${handleAngle},${valveCx},${valveCy})">
<rect class="faucet-handle" x="${valveCx - 3}" y="${valveCy - valveR - 6}" width="6" height="${valveR * 2 + 12}" rx="3" fill="#1e293b" opacity="0.7"/>
</g>
<path class="faucet-spigot" d="${spigotPath}" fill="url(#faucet-pipe-grad)" stroke="#475569" stroke-width="1"/>
<rect class="faucet-stream" x="${valveCx - streamW / 2}" y="${streamY}" width="${streamW}" height="${Math.max(streamH, 4)}" rx="3" fill="#10b981" opacity="${isConfigured ? 0.45 : 0.15}"/>
<text x="${valveCx}" y="${amountY}" text-anchor="middle" fill="#e2e8f0" font-size="13" font-weight="700" font-family="ui-monospace,monospace" pointer-events="none">$${d.flowRate.toLocaleString()}/mo</text>
${allocBar}
${this.renderPortsSvg(n)}
</g>`;
}
@ -2676,6 +2710,11 @@ class FolkFlowsApp extends HTMLElement {
// ─── Inline config panel ─────────────────────────────
private enterInlineEdit(nodeId: string) {
const clickedNode = this.nodes.find((n) => n.id === nodeId);
if (clickedNode?.type === "source") {
this.openSourcePurchaseModal(nodeId);
return;
}
if (this.inlineEditNodeId && this.inlineEditNodeId !== nodeId) {
this.exitInlineEdit();
}
@ -3287,6 +3326,7 @@ class FolkFlowsApp extends HTMLElement {
}
private redrawNodeInlineEdit(node: FlowNode) {
if (node.type === "source") { this.redrawNodeOnly(node); return; }
this.drawCanvasContent();
// Re-enter inline edit to show appropriate handles/panel
this.enterInlineEdit(node.id);
@ -3538,6 +3578,174 @@ class FolkFlowsApp extends HTMLElement {
});
}
// ─── Source Purchase Modal ─────────────────────────────
private openSourcePurchaseModal(nodeId: string) {
if (this.sourceModalNodeId) return; // guard re-entry
const node = this.nodes.find((n) => n.id === nodeId);
if (!node || node.type !== "source") return;
this.sourceModalNodeId = nodeId;
const sd = node.data as SourceNodeData;
const valveColors: Record<string, string> = { card: "#3b82f6", safe_wallet: "#10b981", ridentity: "#7c3aed", metamask: "#f6851b", unconfigured: "#64748b" };
const inputStyle = `width:100%;padding:10px 12px;border:1px solid var(--rflows-modal-border, #334155);border-radius:8px;font-size:14px;box-sizing:border-box;background:var(--rs-bg-surface, #1e293b);color:var(--rs-text-primary, #e2e8f0)`;
const modal = document.createElement("div");
modal.className = "source-modal";
const renderMethodDetail = () => {
const detailEl = modal.querySelector(".spm-method-detail") as HTMLElement;
if (!detailEl) return;
if (sd.sourceType === "card") {
detailEl.innerHTML = `<button class="spm-action-btn" data-spm-action="fund-card" style="width:100%;padding:10px;border:none;border-radius:8px;background:#3b82f6;color:white;font-weight:600;cursor:pointer;margin-top:8px">Fund with Card</button>`;
} else if (sd.sourceType === "metamask") {
const addr = sd.walletAddress ? `<div style="margin-top:8px;font-size:12px;color:var(--rs-text-muted, #94a3b8);word-break:break-all">Connected: ${sd.walletAddress}</div>` : "";
detailEl.innerHTML = `<button class="spm-action-btn" data-spm-action="connect-metamask" style="width:100%;padding:10px;border:none;border-radius:8px;background:#f6851b;color:white;font-weight:600;cursor:pointer;margin-top:8px">${sd.walletAddress ? "Reconnect MetaMask" : "Connect MetaMask"}</button>${addr}`;
} else if (sd.sourceType === "ridentity") {
const session = getSession();
detailEl.innerHTML = `<div style="margin-top:8px;padding:10px;border:1px solid var(--rflows-modal-border, #334155);border-radius:8px;font-size:13px;color:var(--rs-text-secondary, #94a3b8)">${session ? `Linked as <strong style="color:var(--rs-text-primary, #e2e8f0)">${session.claims.username || session.claims.sub}</strong>` : "Not signed in"}</div>`;
} else {
detailEl.innerHTML = "";
}
// Re-attach detail listeners
modal.querySelector("[data-spm-action='fund-card']")?.addEventListener("click", (e: Event) => {
e.stopPropagation();
this.openUserOnRamp(nodeId).catch((err) => console.error("[UserOnRamp] Error:", err));
});
modal.querySelector("[data-spm-action='connect-metamask']")?.addEventListener("click", async (e: Event) => {
e.stopPropagation();
await this.connectMetaMask(nodeId);
renderMethodDetail();
});
};
const updateMethodBtns = () => {
modal.querySelectorAll(".spm-method-btn").forEach((btn) => {
const type = (btn as HTMLElement).dataset.spmType || "";
btn.classList.toggle("spm-method-btn--active", type === sd.sourceType);
});
};
// Build allocation display
let allocHtml = "";
if (sd.targetAllocations && sd.targetAllocations.length > 0) {
const barSegs = sd.targetAllocations.map(a =>
`<div style="flex:${a.percentage};height:6px;background:${a.color};border-radius:2px"></div>`
).join("");
const labels = sd.targetAllocations.map(a =>
`<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--rs-text-secondary, #94a3b8)"><span style="width:8px;height:8px;border-radius:50%;background:${a.color};flex-shrink:0"></span>${this.esc(this.getNodeLabel(a.targetId))}${a.percentage}%</div>`
).join("");
allocHtml = `<div style="margin-top:16px">
<div style="font-size:11px;text-transform:uppercase;font-weight:600;color:var(--rs-text-muted, #64748b);margin-bottom:6px">Allocations</div>
<div style="display:flex;gap:2px;margin-bottom:8px">${barSegs}</div>
<div style="display:flex;flex-direction:column;gap:4px">${labels}</div>
</div>`;
}
modal.innerHTML = `
<div class="spm-backdrop"></div>
<div class="spm-card">
<h3 style="margin:0 0 16px;color:var(--rs-text-primary, #e2e8f0);font-size:17px">Configure Source</h3>
<label style="display:block;margin-bottom:12px">
<span style="display:block;margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary, #94a3b8)">Label</span>
<input class="spm-label-input" type="text" value="${this.esc(sd.label)}" style="${inputStyle}"/>
</label>
<label style="display:block;margin-bottom:12px">
<span style="display:block;margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary, #94a3b8)">Monthly Amount ($)</span>
<input class="spm-amount-input" type="number" min="0" step="50" value="${sd.flowRate}" style="${inputStyle}"/>
</label>
<div style="margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary, #94a3b8)">Payment Method</div>
<div class="spm-method-grid">
<button class="spm-method-btn ${sd.sourceType === "card" ? "spm-method-btn--active" : ""}" data-spm-type="card">
<span style="font-size:20px">\u{1F4B3}</span><span>Card</span>
</button>
<button class="spm-method-btn ${sd.sourceType === "metamask" ? "spm-method-btn--active" : ""}" data-spm-type="metamask">
<span style="font-size:20px">\u{1F98A}</span><span>MetaMask</span>
</button>
<button class="spm-method-btn ${sd.sourceType === "ridentity" ? "spm-method-btn--active" : ""}" data-spm-type="ridentity">
<span style="font-size:20px">\u{1F464}</span><span>rIdentity</span>
</button>
</div>
<div class="spm-method-detail"></div>
${allocHtml}
<div style="display:flex;gap:8px;margin-top:20px;align-items:center">
<button class="spm-delete-btn" style="padding:8px 14px;border:1px solid #ef4444;border-radius:8px;background:none;color:#ef4444;cursor:pointer;font-size:13px">Delete</button>
<div style="flex:1"></div>
<button class="spm-close-btn" style="padding:10px 20px;border:none;border-radius:8px;background:var(--rs-primary, #10b981);color:white;font-weight:600;cursor:pointer;font-size:14px">Save &amp; Close</button>
</div>
</div>`;
document.body.appendChild(modal);
renderMethodDetail();
// Live field updates
const labelInput = modal.querySelector(".spm-label-input") as HTMLInputElement;
const amountInput = modal.querySelector(".spm-amount-input") as HTMLInputElement;
const applyChanges = () => {
this.redrawNodeOnly(node);
this.redrawEdges();
this.scheduleSave();
};
labelInput.addEventListener("input", () => { sd.label = labelInput.value; applyChanges(); });
amountInput.addEventListener("input", () => { sd.flowRate = parseFloat(amountInput.value) || 0; applyChanges(); });
// Method selection
modal.querySelectorAll(".spm-method-btn").forEach((btn) => {
btn.addEventListener("click", (e: Event) => {
e.stopPropagation();
sd.sourceType = ((btn as HTMLElement).dataset.spmType || "unconfigured") as SourceNodeData["sourceType"];
updateMethodBtns();
renderMethodDetail();
applyChanges();
});
});
// Close / Delete
const closeModal = () => {
this.sourceModalNodeId = null;
modal.remove();
};
modal.querySelector(".spm-close-btn")!.addEventListener("click", closeModal);
modal.querySelector(".spm-backdrop")!.addEventListener("click", closeModal);
modal.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Escape") closeModal();
});
modal.querySelector(".spm-delete-btn")!.addEventListener("click", () => {
closeModal();
this.nodes = this.nodes.filter((nn) => nn.id !== nodeId);
this.drawCanvasContent();
this.scheduleSave();
});
labelInput.focus();
}
private async connectMetaMask(nodeId: string) {
const node = this.nodes.find((n) => n.id === nodeId);
if (!node || node.type !== "source") return;
const sd = node.data as SourceNodeData;
const ethereum = (window as any).ethereum;
if (!ethereum) {
alert("MetaMask not detected. Please install the MetaMask browser extension.");
return;
}
try {
const accounts: string[] = await ethereum.request({ method: "eth_requestAccounts" });
const chainId: string = await ethereum.request({ method: "eth_chainId" });
sd.walletAddress = accounts[0];
sd.chainId = parseInt(chainId, 16);
this.redrawNodeOnly(node);
this.redrawEdges();
this.scheduleSave();
} catch (err) {
console.error("[MetaMask] Connection failed:", err);
}
}
/**
* Open on-ramp widget. Coinbase blocks iframing (CSP frame-ancestors),
* so we open it in a popup window. Transak allows iframing.
@ -4036,7 +4244,7 @@ class FolkFlowsApp extends HTMLElement {
if (!node || node.type !== "source") return;
const d = node.data as SourceNodeData;
const icons: Record<string, string> = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" };
const icons: Record<string, string> = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", metamask: "\u{1F98A}", unconfigured: "\u{2699}" };
const labels: Record<string, string> = { card: "Card", safe_wallet: "Safe / Wallet", ridentity: "rIdentity", unconfigured: "Configure" };
let configHtml = "";

View File

@ -90,7 +90,7 @@ export interface OutcomeNodeData {
export interface SourceNodeData {
label: string;
flowRate: number;
sourceType: "card" | "safe_wallet" | "ridentity" | "unconfigured";
sourceType: "card" | "safe_wallet" | "ridentity" | "metamask" | "unconfigured";
targetAllocations: SourceAllocation[];
walletAddress?: string;
chainId?: number;