feat: restructure rFunds — landing page + multi-view TBFF flow

rfunds.online now shows a landing page with TBFF info and flow list
instead of the river demo. The river is one tab in a 3-tab flow detail
view (Table | River | Transactions).

- Add folk-funds-app.ts: main app component with landing + detail views
- Extract mapFlowToNodes to shared lib/map-flow.ts
- Simplify folk-budget-river.ts to pure renderer (no API fetching)
- Restructure routes: / = landing, /demo = demo detail, /flow/:id = flow
- Expand funds.css for landing, tabs, table cards, transaction list
- Add folk-funds-app.ts build entry to vite.config.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-23 19:18:01 -08:00
parent ae8d306a62
commit 5b5c0be732
7 changed files with 921 additions and 33 deletions

View File

@ -1,6 +1,7 @@
/**
* <folk-budget-river> animated SVG sankey river visualization.
* Vanilla web component port of rfunds-online BudgetRiver.tsx.
* Pure renderer: receives nodes via setNodes() or falls back to demo data.
* Parent component (folk-funds-app) handles data fetching and mapping.
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "../lib/types";
@ -386,6 +387,11 @@ class FolkBudgetRiver extends HTMLElement {
private nodes: FlowNode[] = [];
private simulating = false;
private simTimer: ReturnType<typeof setInterval> | null = null;
private dragging = false;
private dragStartX = 0;
private dragStartY = 0;
private scrollStartX = 0;
private scrollStartY = 0;
constructor() {
super();
@ -395,8 +401,10 @@ class FolkBudgetRiver extends HTMLElement {
static get observedAttributes() { return ["simulate"]; }
connectedCallback() {
this.nodes = [...demoNodes.map((n) => ({ ...n, data: { ...n.data } }))];
this.simulating = this.getAttribute("simulate") === "true";
if (this.nodes.length === 0) {
this.nodes = [...demoNodes.map((n) => ({ ...n, data: { ...n.data } }))];
}
this.render();
if (this.simulating) this.startSimulation();
}
@ -437,7 +445,8 @@ class FolkBudgetRiver extends HTMLElement {
this.shadow.innerHTML = `
<style>
:host { display: block; }
.container { position: relative; overflow: auto; background: ${COLORS.bg}; border-radius: 12px; border: 1px solid #334155; }
.container { position: relative; overflow: auto; background: ${COLORS.bg}; border-radius: 12px; border: 1px solid #334155; max-height: 85vh; cursor: grab; }
.container.dragging { cursor: grabbing; user-select: none; }
svg { display: block; }
.controls { position: absolute; top: 12px; left: 12px; display: flex; gap: 8px; }
.controls button { padding: 6px 12px; border-radius: 6px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 12px; }
@ -452,7 +461,7 @@ class FolkBudgetRiver extends HTMLElement {
@keyframes waveFloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
</style>
<div class="container">
<svg viewBox="0 0 ${layout.width} ${layout.height}" width="${Math.min(layout.width, 1200)}" height="${Math.min(layout.height, 800)}">
<svg viewBox="0 0 ${layout.width} ${layout.height}" width="${layout.width}" height="${layout.height}">
${layout.sourceWaterfalls.map(renderWaterfall).join("")}
${layout.spendingWaterfalls.map(renderWaterfall).join("")}
${layout.overflowBranches.map(renderBranch).join("")}
@ -480,6 +489,35 @@ class FolkBudgetRiver extends HTMLElement {
else this.stopSimulation();
this.render();
});
// Drag-to-pan
const container = this.shadow.querySelector(".container") as HTMLElement;
if (container) {
container.addEventListener("pointerdown", (e: PointerEvent) => {
if ((e.target as HTMLElement).closest("button")) return;
this.dragging = true;
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.scrollStartX = container.scrollLeft;
this.scrollStartY = container.scrollTop;
container.classList.add("dragging");
container.setPointerCapture(e.pointerId);
});
container.addEventListener("pointermove", (e: PointerEvent) => {
if (!this.dragging) return;
container.scrollLeft = this.scrollStartX - (e.clientX - this.dragStartX);
container.scrollTop = this.scrollStartY - (e.clientY - this.dragStartY);
});
container.addEventListener("pointerup", (e: PointerEvent) => {
this.dragging = false;
container.classList.remove("dragging");
container.releasePointerCapture(e.pointerId);
});
// Auto-center on initial render
container.scrollLeft = (container.scrollWidth - container.clientWidth) / 2;
container.scrollTop = 0;
}
}
}

View File

@ -0,0 +1,558 @@
/**
* <folk-funds-app> main rFunds application component.
*
* Views:
* "landing" TBFF info hero + flow list cards
* "detail" Flow detail with tabs: Table | River | Transactions
*
* Attributes:
* space space slug
* flow-id if set, go straight to detail view
* mode "demo" to use hardcoded demo data (no API)
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from "../lib/types";
import { computeSufficiencyState } from "../lib/simulation";
import { demoNodes } from "../lib/presets";
import { mapFlowToNodes } from "../lib/map-flow";
interface FlowSummary {
id: string;
name: string;
label?: string;
status?: string;
funnelCount?: number;
outcomeCount?: number;
totalValue?: number;
}
interface Transaction {
id: string;
type: string;
amount: number;
from?: string;
to?: string;
timestamp: string;
description?: string;
}
type View = "landing" | "detail";
type Tab = "table" | "river" | "transactions";
class FolkFundsApp extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private view: View = "landing";
private tab: Tab = "table";
private flowId = "";
private isDemo = false;
private flows: FlowSummary[] = [];
private nodes: FlowNode[] = [];
private flowName = "";
private transactions: Transaction[] = [];
private txLoaded = false;
private loading = false;
private error = "";
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.flowId = this.getAttribute("flow-id") || "";
this.isDemo = this.getAttribute("mode") === "demo";
if (this.isDemo) {
this.view = "detail";
this.flowName = "TBFF Demo Flow";
this.nodes = demoNodes.map((n) => ({ ...n, data: { ...n.data } }));
this.render();
} else if (this.flowId) {
this.view = "detail";
this.loadFlow(this.flowId);
} else {
this.view = "landing";
this.loadFlows();
}
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^\/([^/]+)\/funds/);
return match ? `/${match[1]}/funds` : "";
}
private async loadFlows() {
this.loading = true;
this.render();
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/flows`);
if (res.ok) {
const data = await res.json();
this.flows = Array.isArray(data) ? data : (data.flows || []);
}
} catch {
// Flow service unavailable — landing page still works with demo link
}
this.loading = false;
this.render();
}
private async loadFlow(flowId: string) {
this.loading = true;
this.render();
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/flows/${encodeURIComponent(flowId)}`);
if (res.ok) {
const data = await res.json();
this.nodes = mapFlowToNodes(data);
this.flowName = data.name || data.label || flowId;
} else {
this.error = `Flow not found (${res.status})`;
}
} catch {
this.error = "Failed to load flow";
}
this.loading = false;
this.render();
}
private async loadTransactions() {
if (this.txLoaded || this.isDemo) return;
this.loading = true;
this.render();
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/flows/${encodeURIComponent(this.flowId)}/transactions`);
if (res.ok) {
const data = await res.json();
this.transactions = Array.isArray(data) ? data : (data.transactions || []);
}
} catch {
// Transactions unavailable
}
this.txLoaded = true;
this.loading = false;
this.render();
}
private getCssPath(): string {
// In rSpace: /modules/funds/funds.css | Standalone: /modules/funds/funds.css
// The shell always serves from /modules/funds/ in both modes
return "/modules/funds/funds.css";
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e2e8f0; }
*, *::before, *::after { box-sizing: border-box; }
</style>
<link rel="stylesheet" href="${this.getCssPath()}">
${this.error ? `<div class="funds-error">${this.esc(this.error)}</div>` : ""}
${this.loading && this.view === "landing" ? '<div class="funds-loading">Loading...</div>' : ""}
${this.renderView()}
`;
this.attachListeners();
}
private renderView(): string {
if (this.view === "detail") return this.renderDetail();
return this.renderLanding();
}
// ─── Landing page ──────────────────────────────────────
private renderLanding(): string {
const demoUrl = this.getApiBase() ? `${this.getApiBase().replace(/\/funds$/, "")}/funds/demo` : "/demo";
return `
<div class="funds-landing">
<div class="funds-hero">
<h1 class="funds-hero__title">rFunds</h1>
<p class="funds-hero__subtitle">Token Bonding Flow Funnel</p>
<p class="funds-hero__desc">
Distribute resources through cascading funnels with sufficiency-based overflow.
Each funnel fills to its threshold, then excess flows to the next layer &mdash;
ensuring every level has <em>enough</em> before abundance cascades forward.
</p>
<a href="${this.esc(demoUrl)}" class="funds-hero__cta">Try the Demo &rarr;</a>
</div>
<div class="funds-flows">
<h2 class="funds-flows__heading">Your Flows</h2>
${this.flows.length > 0 ? `
<div class="funds-flows__grid">
${this.flows.map((f) => this.renderFlowCard(f)).join("")}
</div>
` : `
<div class="funds-flows__empty">
<p>No flows yet.</p>
<p>
<a href="${this.esc(demoUrl)}">Explore the demo</a> to see how TBFF works.
</p>
</div>
`}
</div>
<div class="funds-about">
<h2 class="funds-about__heading">How TBFF Works</h2>
<div class="funds-about__grid">
<div class="funds-about__card">
<div class="funds-about__icon">&#x1F30A;</div>
<h3>Sources</h3>
<p>Revenue streams and deposits flow into the system, split across funnels by configurable allocation percentages.</p>
</div>
<div class="funds-about__card">
<div class="funds-about__icon">&#x1F3DB;</div>
<h3>Funnels</h3>
<p>Budget buckets with min/max thresholds. When a funnel reaches sufficiency, overflow cascades to the next layer.</p>
</div>
<div class="funds-about__card">
<div class="funds-about__icon">&#x1F3AF;</div>
<h3>Outcomes</h3>
<p>Funding targets that receive spending allocations from funnels. Track progress toward each goal.</p>
</div>
</div>
</div>
</div>`;
}
private renderFlowCard(f: FlowSummary): string {
const detailUrl = this.getApiBase()
? `${this.getApiBase().replace(/\/funds$/, "")}/funds/flow/${encodeURIComponent(f.id)}`
: `/flow/${encodeURIComponent(f.id)}`;
const value = f.totalValue != null ? `$${Math.floor(f.totalValue).toLocaleString()}` : "";
return `
<a href="${this.esc(detailUrl)}" class="funds-flow-card" data-flow="${this.esc(f.id)}">
<div class="funds-flow-card__name">${this.esc(f.name || f.label || f.id)}</div>
${value ? `<div class="funds-flow-card__value">${value}</div>` : ""}
<div class="funds-flow-card__meta">
${f.funnelCount != null ? `${f.funnelCount} funnels` : ""}
${f.outcomeCount != null ? ` &middot; ${f.outcomeCount} outcomes` : ""}
${f.status ? ` &middot; ${f.status}` : ""}
</div>
</a>`;
}
// ─── Detail view with tabs ─────────────────────────────
private renderDetail(): string {
const backUrl = this.getApiBase()
? `${this.getApiBase().replace(/\/funds$/, "")}/funds/`
: "/";
return `
<div class="funds-detail">
<div class="funds-detail__header">
<a href="${this.esc(backUrl)}" class="funds-detail__back">&larr; All Flows</a>
<h1 class="funds-detail__title">${this.esc(this.flowName || "Flow Detail")}</h1>
${this.isDemo ? '<span class="funds-detail__badge">Demo</span>' : ""}
</div>
<div class="funds-tabs">
<button class="funds-tab ${this.tab === "table" ? "funds-tab--active" : ""}" data-tab="table">Table</button>
<button class="funds-tab ${this.tab === "river" ? "funds-tab--active" : ""}" data-tab="river">River</button>
<button class="funds-tab ${this.tab === "transactions" ? "funds-tab--active" : ""}" data-tab="transactions">Transactions</button>
</div>
<div class="funds-tab-content">
${this.loading ? '<div class="funds-loading">Loading...</div>' : this.renderTab()}
</div>
</div>`;
}
private renderTab(): string {
if (this.tab === "river") return this.renderRiverTab();
if (this.tab === "transactions") return this.renderTransactionsTab();
return this.renderTableTab();
}
// ─── Table tab ────────────────────────────────────────
private renderTableTab(): string {
const funnels = this.nodes.filter((n) => n.type === "funnel");
const outcomes = this.nodes.filter((n) => n.type === "outcome");
const sources = this.nodes.filter((n) => n.type === "source");
return `
<div class="funds-table">
${sources.length > 0 ? `
<div class="funds-section">
<h3 class="funds-section__title">Sources</h3>
<div class="funds-cards">
${sources.map((n) => this.renderSourceCard(n.data as SourceNodeData, n.id)).join("")}
</div>
</div>
` : ""}
<div class="funds-section">
<h3 class="funds-section__title">Funnels</h3>
<div class="funds-cards">
${funnels.map((n) => this.renderFunnelCard(n.data as FunnelNodeData, n.id)).join("")}
</div>
</div>
<div class="funds-section">
<h3 class="funds-section__title">Outcomes</h3>
<div class="funds-cards">
${outcomes.map((n) => this.renderOutcomeCard(n.data as OutcomeNodeData, n.id)).join("")}
</div>
</div>
</div>`;
}
private renderSourceCard(data: SourceNodeData, id: string): string {
const allocations = data.targetAllocations || [];
return `
<div class="funds-card">
<div class="funds-card__header">
<span class="funds-card__icon">&#x1F4B0;</span>
<span class="funds-card__label">${this.esc(data.label)}</span>
<span class="funds-card__type">${data.sourceType}</span>
</div>
<div class="funds-card__stat">
<span class="funds-card__stat-value">$${data.flowRate.toLocaleString()}</span>
<span class="funds-card__stat-label">/month</span>
</div>
${allocations.length > 0 ? `
<div class="funds-card__allocs">
${allocations.map((a) => `
<div class="funds-card__alloc">
<span class="funds-card__alloc-dot" style="background:${a.color}"></span>
${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
</div>
`).join("")}
</div>
` : ""}
</div>`;
}
private renderFunnelCard(data: FunnelNodeData, id: string): string {
const sufficiency = computeSufficiencyState(data);
const threshold = data.sufficientThreshold ?? data.maxThreshold;
const fillPct = Math.min(100, (data.currentValue / (data.maxCapacity || 1)) * 100);
const suffPct = Math.min(100, (data.currentValue / (threshold || 1)) * 100);
const statusClass = sufficiency === "abundant" ? "funds-status--abundant"
: sufficiency === "sufficient" ? "funds-status--sufficient"
: data.currentValue < data.minThreshold ? "funds-status--critical"
: "funds-status--seeking";
const statusLabel = sufficiency === "abundant" ? "Abundant"
: sufficiency === "sufficient" ? "Sufficient"
: data.currentValue < data.minThreshold ? "Critical"
: "Seeking";
return `
<div class="funds-card">
<div class="funds-card__header">
<span class="funds-card__icon">&#x1F3DB;</span>
<span class="funds-card__label">${this.esc(data.label)}</span>
<span class="funds-card__status ${statusClass}">${statusLabel}</span>
</div>
<div class="funds-card__bar-container">
<div class="funds-card__bar" style="width:${fillPct}%"></div>
<div class="funds-card__bar-threshold" style="left:${Math.min(100, (threshold / (data.maxCapacity || 1)) * 100)}%"></div>
</div>
<div class="funds-card__stats">
<div>
<span class="funds-card__stat-value">$${Math.floor(data.currentValue).toLocaleString()}</span>
<span class="funds-card__stat-label">/ $${Math.floor(threshold).toLocaleString()}</span>
</div>
<div>
<span class="funds-card__stat-value">${Math.round(suffPct)}%</span>
<span class="funds-card__stat-label">sufficiency</span>
</div>
</div>
<div class="funds-card__thresholds">
<span>Min: $${Math.floor(data.minThreshold).toLocaleString()}</span>
<span>Max: $${Math.floor(data.maxThreshold).toLocaleString()}</span>
<span>Cap: $${Math.floor(data.maxCapacity).toLocaleString()}</span>
</div>
${data.overflowAllocations.length > 0 ? `
<div class="funds-card__allocs">
<div class="funds-card__alloc-title">Overflow</div>
${data.overflowAllocations.map((a) => `
<div class="funds-card__alloc">
<span class="funds-card__alloc-dot" style="background:${a.color}"></span>
${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
</div>
`).join("")}
</div>
` : ""}
${data.spendingAllocations.length > 0 ? `
<div class="funds-card__allocs">
<div class="funds-card__alloc-title">Spending</div>
${data.spendingAllocations.map((a) => `
<div class="funds-card__alloc">
<span class="funds-card__alloc-dot" style="background:${a.color}"></span>
${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
</div>
`).join("")}
</div>
` : ""}
</div>`;
}
private renderOutcomeCard(data: OutcomeNodeData, id: string): string {
const fillPct = data.fundingTarget > 0
? Math.min(100, (data.fundingReceived / data.fundingTarget) * 100)
: 0;
const statusColor = data.status === "completed" ? "#10b981"
: data.status === "blocked" ? "#ef4444"
: data.status === "in-progress" ? "#3b82f6"
: "#64748b";
return `
<div class="funds-card">
<div class="funds-card__header">
<span class="funds-card__icon">&#x1F3AF;</span>
<span class="funds-card__label">${this.esc(data.label)}</span>
<span class="funds-card__status" style="color:${statusColor}">${data.status}</span>
</div>
${data.description ? `<div class="funds-card__desc">${this.esc(data.description)}</div>` : ""}
<div class="funds-card__bar-container">
<div class="funds-card__bar funds-card__bar--outcome" style="width:${fillPct}%;background:${statusColor}"></div>
</div>
<div class="funds-card__stats">
<div>
<span class="funds-card__stat-value">$${Math.floor(data.fundingReceived).toLocaleString()}</span>
<span class="funds-card__stat-label">/ $${Math.floor(data.fundingTarget).toLocaleString()}</span>
</div>
<div>
<span class="funds-card__stat-value">${Math.round(fillPct)}%</span>
<span class="funds-card__stat-label">funded</span>
</div>
</div>
</div>`;
}
private getNodeLabel(id: string): string {
const node = this.nodes.find((n) => n.id === id);
if (!node) return id;
return (node.data as any).label || id;
}
// ─── River tab ────────────────────────────────────────
private renderRiverTab(): string {
return `<div class="funds-river-container" id="river-mount"></div>`;
}
private mountRiver() {
const mount = this.shadow.getElementById("river-mount");
if (!mount) return;
// Check if already mounted
if (mount.querySelector("folk-budget-river")) return;
const river = document.createElement("folk-budget-river") as any;
river.setAttribute("simulate", "true");
mount.appendChild(river);
// Pass nodes after the element is connected
requestAnimationFrame(() => {
if (typeof river.setNodes === "function") {
river.setNodes(this.nodes.map((n) => ({ ...n, data: { ...n.data } })));
}
});
}
// ─── Transactions tab ─────────────────────────────────
private renderTransactionsTab(): string {
if (this.isDemo) {
return `
<div class="funds-tx-empty">
<p>Transaction history is not available in demo mode.</p>
</div>`;
}
if (!this.txLoaded) {
return '<div class="funds-loading">Loading transactions...</div>';
}
if (this.transactions.length === 0) {
return `
<div class="funds-tx-empty">
<p>No transactions yet for this flow.</p>
</div>`;
}
return `
<div class="funds-tx-list">
${this.transactions.map((tx) => `
<div class="funds-tx">
<div class="funds-tx__icon">${tx.type === "deposit" ? "&#x2B06;" : tx.type === "withdraw" ? "&#x2B07;" : "&#x1F504;"}</div>
<div class="funds-tx__body">
<div class="funds-tx__desc">${this.esc(tx.description || tx.type)}</div>
<div class="funds-tx__meta">
${tx.from ? `From: ${this.esc(tx.from)}` : ""}
${tx.to ? ` &rarr; ${this.esc(tx.to)}` : ""}
</div>
</div>
<div class="funds-tx__amount ${tx.type === "deposit" ? "funds-tx__amount--positive" : "funds-tx__amount--negative"}">
${tx.type === "deposit" ? "+" : "-"}$${Math.abs(tx.amount).toLocaleString()}
</div>
<div class="funds-tx__time">${this.formatTime(tx.timestamp)}</div>
</div>
`).join("")}
</div>`;
}
private formatTime(ts: string): string {
try {
const d = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
if (diffDays === 1) return "Yesterday";
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString();
} catch {
return ts;
}
}
// ─── Event listeners ──────────────────────────────────
private attachListeners() {
// Tab switching
this.shadow.querySelectorAll("[data-tab]").forEach((el) => {
el.addEventListener("click", () => {
const newTab = (el as HTMLElement).dataset.tab as Tab;
if (newTab === this.tab) return;
this.tab = newTab;
this.render();
if (newTab === "transactions" && !this.txLoaded) {
this.loadTransactions();
}
});
});
// Mount river component when river tab is active
if (this.tab === "river" && this.nodes.length > 0) {
this.mountRiver();
}
}
private esc(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
}
customElements.define("folk-funds-app", FolkFundsApp);

View File

@ -1,6 +1,162 @@
/* Funds module theme */
/* ── Funds module theme ───────────────────────────────── */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 52px);
padding: 0;
}
/* ── Shared utility classes ──────────────────────────── */
.funds-loading { text-align: center; color: #64748b; padding: 48px 16px; font-size: 14px; }
.funds-error { text-align: center; color: #ef4444; padding: 20px 16px; font-size: 14px; }
/* ── Landing page ────────────────────────────────────── */
.funds-landing { max-width: 960px; margin: 0 auto; padding: 24px 20px 64px; }
.funds-hero {
text-align: center;
padding: 48px 20px 40px;
border-bottom: 1px solid #1e293b;
margin-bottom: 40px;
}
.funds-hero__title {
font-size: 36px; font-weight: 700; margin: 0 0 8px;
background: linear-gradient(135deg, #0ea5e9, #6366f1, #ec4899);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.funds-hero__subtitle { font-size: 18px; color: #94a3b8; margin: 0 0 16px; font-weight: 500; }
.funds-hero__desc { font-size: 14px; color: #64748b; line-height: 1.7; max-width: 560px; margin: 0 auto 24px; }
.funds-hero__cta {
display: inline-block; padding: 10px 24px; border-radius: 8px;
background: #4f46e5; color: #fff; text-decoration: none; font-weight: 600; font-size: 14px;
transition: background 0.2s;
}
.funds-hero__cta:hover { background: #6366f1; }
/* Flow list */
.funds-flows { margin-bottom: 48px; }
.funds-flows__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0 0 16px; }
.funds-flows__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
.funds-flows__empty { text-align: center; color: #64748b; padding: 32px 16px; font-size: 14px; }
.funds-flows__empty a { color: #6366f1; text-decoration: none; }
.funds-flows__empty a:hover { text-decoration: underline; }
.funds-flow-card {
display: block; text-decoration: none;
background: #1e293b; border: 1px solid #334155; border-radius: 10px;
padding: 16px; cursor: pointer; transition: border-color 0.2s, transform 0.15s;
}
.funds-flow-card:hover { border-color: #6366f1; transform: translateY(-1px); }
.funds-flow-card__name { font-size: 15px; font-weight: 600; color: #e2e8f0; margin-bottom: 4px; }
.funds-flow-card__value { font-size: 20px; font-weight: 700; color: #0ea5e9; margin-bottom: 4px; }
.funds-flow-card__meta { font-size: 12px; color: #64748b; }
/* About / how-it-works section */
.funds-about { margin-bottom: 48px; }
.funds-about__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0 0 16px; }
.funds-about__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
.funds-about__card {
background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 20px;
}
.funds-about__icon { font-size: 28px; margin-bottom: 8px; }
.funds-about__card h3 { font-size: 15px; font-weight: 600; color: #e2e8f0; margin: 0 0 8px; }
.funds-about__card p { font-size: 13px; color: #94a3b8; line-height: 1.6; margin: 0; }
/* ── Detail view ─────────────────────────────────────── */
.funds-detail { max-width: 1100px; margin: 0 auto; padding: 16px 20px 64px; }
.funds-detail__header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.funds-detail__back {
color: #64748b; text-decoration: none; font-size: 13px; padding: 4px 0;
}
.funds-detail__back:hover { color: #e2e8f0; }
.funds-detail__title { font-size: 22px; font-weight: 700; color: #e2e8f0; margin: 0; flex: 1; }
.funds-detail__badge {
font-size: 11px; font-weight: 600; color: #fbbf24; background: rgba(251,191,36,0.15);
border: 1px solid rgba(251,191,36,0.3); border-radius: 4px; padding: 2px 8px;
}
/* ── Tabs ────────────────────────────────────────────── */
.funds-tabs {
display: flex; gap: 4px; border-bottom: 1px solid #1e293b; margin-bottom: 20px;
}
.funds-tab {
padding: 8px 18px; border: none; border-bottom: 2px solid transparent;
background: transparent; color: #64748b; font-size: 13px; font-weight: 500;
cursor: pointer; transition: color 0.2s, border-color 0.2s;
}
.funds-tab:hover { color: #e2e8f0; }
.funds-tab--active { color: #e2e8f0; border-bottom-color: #6366f1; }
.funds-tab-content { min-height: 300px; }
/* ── Table tab — card grid ───────────────────────────── */
.funds-table { }
.funds-section { margin-bottom: 28px; }
.funds-section__title { font-size: 14px; font-weight: 600; color: #94a3b8; margin: 0 0 12px; text-transform: uppercase; letter-spacing: 0.05em; }
.funds-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.funds-card {
background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 16px;
}
.funds-card__header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.funds-card__icon { font-size: 18px; }
.funds-card__label { font-size: 14px; font-weight: 600; color: #e2e8f0; flex: 1; }
.funds-card__type { font-size: 11px; color: #64748b; text-transform: uppercase; }
.funds-card__status { font-size: 11px; font-weight: 600; text-transform: capitalize; }
.funds-card__desc { font-size: 12px; color: #94a3b8; margin-bottom: 10px; line-height: 1.5; }
.funds-card__stat { margin-bottom: 10px; }
.funds-card__stat-value { font-size: 18px; font-weight: 700; color: #e2e8f0; }
.funds-card__stat-label { font-size: 12px; color: #64748b; margin-left: 4px; }
.funds-card__stats { display: flex; justify-content: space-between; margin-bottom: 8px; }
/* Progress bar */
.funds-card__bar-container {
position: relative; height: 6px; background: #334155; border-radius: 3px;
margin-bottom: 10px; overflow: visible;
}
.funds-card__bar {
height: 100%; border-radius: 3px; background: #0ea5e9;
transition: width 0.3s ease;
}
.funds-card__bar--outcome { opacity: 0.8; }
.funds-card__bar-threshold {
position: absolute; top: -3px; width: 2px; height: 12px;
background: #fbbf24; border-radius: 1px;
}
.funds-card__thresholds {
display: flex; gap: 12px; font-size: 11px; color: #64748b; margin-bottom: 8px;
}
/* Allocation lists */
.funds-card__allocs { margin-top: 8px; padding-top: 8px; border-top: 1px solid #334155; }
.funds-card__alloc-title { font-size: 11px; color: #64748b; font-weight: 600; margin-bottom: 4px; text-transform: uppercase; }
.funds-card__alloc { font-size: 12px; color: #94a3b8; display: flex; align-items: center; gap: 6px; margin: 2px 0; }
.funds-card__alloc-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
/* Status colors */
.funds-status--abundant { color: #fbbf24; }
.funds-status--sufficient { color: #10b981; }
.funds-status--seeking { color: #0ea5e9; }
.funds-status--critical { color: #ef4444; }
/* ── River tab ───────────────────────────────────────── */
.funds-river-container { min-height: 500px; }
/* ── Transactions tab ────────────────────────────────── */
.funds-tx-list { display: flex; flex-direction: column; gap: 4px; }
.funds-tx-empty { text-align: center; color: #64748b; padding: 48px 16px; font-size: 14px; }
.funds-tx {
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
background: #1e293b; border: 1px solid #334155; border-radius: 8px;
}
.funds-tx__icon { font-size: 16px; flex-shrink: 0; }
.funds-tx__body { flex: 1; min-width: 0; }
.funds-tx__desc { font-size: 13px; color: #e2e8f0; font-weight: 500; }
.funds-tx__meta { font-size: 11px; color: #64748b; margin-top: 2px; }
.funds-tx__amount { font-size: 14px; font-weight: 600; white-space: nowrap; }
.funds-tx__amount--positive { color: #10b981; }
.funds-tx__amount--negative { color: #ef4444; }
.funds-tx__time { font-size: 11px; color: #64748b; white-space: nowrap; }

View File

@ -0,0 +1,85 @@
/**
* Maps TBFF API response data to FlowNode[] for visualization.
* Shared between folk-funds-app (data loading) and folk-budget-river (rendering).
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from "./types";
const SPENDING_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#06b6d4", "#10b981", "#6366f1"];
const OVERFLOW_COLORS = ["#f59e0b", "#ef4444", "#f97316", "#eab308", "#dc2626", "#ea580c"];
export function mapFlowToNodes(apiData: any): FlowNode[] {
const nodes: FlowNode[] = [];
// Map sources (income/deposit streams)
if (apiData.sources) {
for (const src of apiData.sources) {
nodes.push({
id: src.id,
type: "source",
position: { x: 0, y: 0 },
data: {
label: src.label || src.name || "Source",
flowRate: src.flowRate ?? src.amount ?? 0,
sourceType: src.sourceType || "recurring",
targetAllocations: (src.targetAllocations || src.allocations || []).map((a: any, i: number) => ({
targetId: a.targetId,
percentage: a.percentage,
color: a.color || SPENDING_COLORS[i % SPENDING_COLORS.length],
})),
} as SourceNodeData,
});
}
}
// Map funnels (budget buckets)
if (apiData.funnels) {
for (const funnel of apiData.funnels) {
nodes.push({
id: funnel.id,
type: "funnel",
position: { x: 0, y: 0 },
data: {
label: funnel.label || funnel.name || "Funnel",
currentValue: funnel.currentValue ?? funnel.balance ?? 0,
minThreshold: funnel.minThreshold ?? 0,
maxThreshold: funnel.maxThreshold ?? funnel.currentValue ?? 10000,
maxCapacity: funnel.maxCapacity ?? funnel.maxThreshold ?? 100000,
inflowRate: funnel.inflowRate ?? 0,
sufficientThreshold: funnel.sufficientThreshold,
dynamicOverflow: funnel.dynamicOverflow ?? false,
overflowAllocations: (funnel.overflowAllocations || []).map((a: any, i: number) => ({
targetId: a.targetId,
percentage: a.percentage,
color: a.color || OVERFLOW_COLORS[i % OVERFLOW_COLORS.length],
})),
spendingAllocations: (funnel.spendingAllocations || []).map((a: any, i: number) => ({
targetId: a.targetId,
percentage: a.percentage,
color: a.color || SPENDING_COLORS[i % SPENDING_COLORS.length],
})),
} as FunnelNodeData,
});
}
}
// Map outcomes (funding targets)
if (apiData.outcomes) {
for (const outcome of apiData.outcomes) {
nodes.push({
id: outcome.id,
type: "outcome",
position: { x: 0, y: 0 },
data: {
label: outcome.label || outcome.name || "Outcome",
description: outcome.description || "",
fundingReceived: outcome.fundingReceived ?? outcome.received ?? 0,
fundingTarget: outcome.fundingTarget ?? outcome.target ?? 0,
status: outcome.status || "not-started",
} as OutcomeNodeData,
});
}
}
return nodes;
}

View File

@ -93,19 +93,57 @@ routes.get("/api/flows/:flowId/transactions", async (c) => {
return c.json(await res.json(), res.status as any);
});
// ─── Page route ────────────────────────────────────────
// ─── Page routes ────────────────────────────────────────
const fundsScripts = `
<script type="module" src="/modules/funds/folk-funds-app.js"></script>
<script type="module" src="/modules/funds/folk-budget-river.js"></script>`;
const fundsStyles = `<link rel="stylesheet" href="/modules/funds/funds.css">`;
// Landing page — TBFF info + flow list
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${spaceSlug} — Funds | rSpace`,
title: `rFunds — TBFF Flow Funding | rSpace`,
moduleId: "funds",
spaceSlug,
modules: getModuleInfoList(),
theme: "light",
styles: `<link rel="stylesheet" href="/modules/funds/funds.css">`,
body: `<folk-budget-river simulate="true"></folk-budget-river>`,
scripts: `<script type="module" src="/modules/funds/folk-budget-river.js"></script>`,
styles: fundsStyles,
body: `<folk-funds-app space="${spaceSlug}"></folk-funds-app>`,
scripts: fundsScripts,
}));
});
// Demo mode — hardcoded demo data, no API needed
routes.get("/demo", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
title: `TBFF Demo — rFunds | rSpace`,
moduleId: "funds",
spaceSlug,
modules: getModuleInfoList(),
theme: "light",
styles: fundsStyles,
body: `<folk-funds-app space="${spaceSlug}" mode="demo"></folk-funds-app>`,
scripts: fundsScripts,
}));
});
// Flow detail — specific flow from API
routes.get("/flow/:flowId", (c) => {
const spaceSlug = c.req.param("space") || "demo";
const flowId = c.req.param("flowId");
return c.html(renderShell({
title: `Flow — rFunds | rSpace`,
moduleId: "funds",
spaceSlug,
modules: getModuleInfoList(),
theme: "light",
styles: fundsStyles,
body: `<folk-funds-app space="${spaceSlug}" flow-id="${flowId}"></folk-funds-app>`,
scripts: fundsScripts,
}));
});

View File

@ -25,19 +25,19 @@ Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname !== "/" && !url.pathname.startsWith("/api/")) {
const assetPath = url.pathname.slice(1);
if (assetPath.includes(".")) {
const file = Bun.file(resolve(DIST_DIR, assetPath));
if (await file.exists()) {
const ct = assetPath.endsWith(".js") ? "application/javascript" :
assetPath.endsWith(".css") ? "text/css" :
assetPath.endsWith(".html") ? "text/html" :
"application/octet-stream";
return new Response(file, { headers: { "Content-Type": ct } });
}
// Serve static assets (JS, CSS, etc.) from dist/
const assetPath = url.pathname.slice(1);
if (assetPath.includes(".")) {
const file = Bun.file(resolve(DIST_DIR, assetPath));
if (await file.exists()) {
const ct = assetPath.endsWith(".js") ? "application/javascript" :
assetPath.endsWith(".css") ? "text/css" :
assetPath.endsWith(".html") ? "text/html" :
"application/octet-stream";
return new Response(file, { headers: { "Content-Type": ct } });
}
}
// All other routes (/, /demo, /flow/:id, /api/*) handled by Hono
return app.fetch(req);
},
});

View File

@ -241,17 +241,18 @@ export default defineConfig({
resolve(__dirname, "dist/modules/choices/choices.css"),
);
// Build funds module component
// Build funds module components
const fundsAlias = {
"../lib/types": resolve(__dirname, "modules/funds/lib/types.ts"),
"../lib/simulation": resolve(__dirname, "modules/funds/lib/simulation.ts"),
"../lib/presets": resolve(__dirname, "modules/funds/lib/presets.ts"),
"../lib/map-flow": resolve(__dirname, "modules/funds/lib/map-flow.ts"),
};
await build({
configFile: false,
root: resolve(__dirname, "modules/funds/components"),
resolve: {
alias: {
"../lib/types": resolve(__dirname, "modules/funds/lib/types.ts"),
"../lib/simulation": resolve(__dirname, "modules/funds/lib/simulation.ts"),
"../lib/presets": resolve(__dirname, "modules/funds/lib/presets.ts"),
},
},
resolve: { alias: fundsAlias },
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/funds"),
@ -260,11 +261,23 @@ export default defineConfig({
formats: ["es"],
fileName: () => "folk-budget-river.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-budget-river.js",
},
rollupOptions: { output: { entryFileNames: "folk-budget-river.js" } },
},
});
await build({
configFile: false,
root: resolve(__dirname, "modules/funds/components"),
resolve: { alias: fundsAlias },
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/funds"),
lib: {
entry: resolve(__dirname, "modules/funds/components/folk-funds-app.ts"),
formats: ["es"],
fileName: () => "folk-funds-app.js",
},
rollupOptions: { output: { entryFileNames: "folk-funds-app.js" } },
},
});