Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-03 19:13:27 -08:00
commit 453fd7e6b4
31 changed files with 560 additions and 473 deletions

View File

@ -109,11 +109,11 @@ services:
traefik.http.routers.rchoices-sa.entrypoints: web
traefik.http.services.rchoices-sa.loadbalancer.server.port: "3000"
# ── rFunds ──
rfunds-standalone:
# ── rFlows ──
rflows-standalone:
<<: *standalone-base
container_name: rfunds-standalone
command: ["bun", "run", "modules/rfunds/standalone.ts"]
container_name: rflows-standalone
command: ["bun", "run", "modules/rflows/standalone.ts"]
environment:
<<: *base-env
FLOW_SERVICE_URL: http://payment-flow:3010
@ -125,9 +125,9 @@ services:
- payment-network
labels:
<<: *traefik-enabled
traefik.http.routers.rfunds-sa.rule: Host(`rfunds.online`)
traefik.http.routers.rfunds-sa.entrypoints: web
traefik.http.services.rfunds-sa.loadbalancer.server.port: "3000"
traefik.http.routers.rflows-sa.rule: Host(`rflows.online`)
traefik.http.routers.rflows-sa.entrypoints: web
traefik.http.services.rflows-sa.loadbalancer.server.port: "3000"
# ── rFiles ──
rfiles-standalone:

View File

@ -75,10 +75,10 @@ services:
- "traefik.http.routers.rspace-rchoices.entrypoints=web"
- "traefik.http.routers.rspace-rchoices.priority=120"
- "traefik.http.routers.rspace-rchoices.service=rspace-online"
- "traefik.http.routers.rspace-rfunds.rule=Host(`rfunds.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rfunds.online`)"
- "traefik.http.routers.rspace-rfunds.entrypoints=web"
- "traefik.http.routers.rspace-rfunds.priority=120"
- "traefik.http.routers.rspace-rfunds.service=rspace-online"
- "traefik.http.routers.rspace-rflows.rule=Host(`rflows.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rflows.online`)"
- "traefik.http.routers.rspace-rflows.entrypoints=web"
- "traefik.http.routers.rspace-rflows.priority=120"
- "traefik.http.routers.rspace-rflows.service=rspace-online"
- "traefik.http.routers.rspace-rforum.rule=Host(`rforum.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rforum.online`)"
- "traefik.http.routers.rspace-rforum.entrypoints=web"
- "traefik.http.routers.rspace-rforum.priority=120"

View File

@ -26,7 +26,7 @@ const MODULE_META: Record<string, { badge: string; color: string; name: string;
rforum: { badge: "rFo", color: "#fcd34d", name: "rForum", icon: "💬" },
rinbox: { badge: "rI", color: "#a5b4fc", name: "rInbox", icon: "📧" },
rtube: { badge: "rTu", color: "#f9a8d4", name: "rTube", icon: "🎬" },
rfunds: { badge: "rF", color: "#bef264", name: "rFunds", icon: "🌊" },
rflows: { badge: "rFl", color: "#bef264", name: "rFlows", icon: "🌊" },
rwallet: { badge: "rW", color: "#fde047", name: "rWallet", icon: "💰" },
rvote: { badge: "rV", color: "#c4b5fd", name: "rVote", icon: "🗳️" },
rcart: { badge: "rCt", color: "#fdba74", name: "rCart", icon: "🛒" },
@ -477,7 +477,7 @@ const WIDGET_API: Record<string, { path: string; transform: (data: any) => Widge
};
},
},
rfunds: {
rflows: {
path: "/api/flows",
transform: (data) => {
const flows = Array.isArray(data) ? data : [];

View File

@ -191,11 +191,11 @@ function seedDemoIfEmpty(space: string) {
sourceId: sprintsId, allDay: true,
},
{
title: "rFunds Budget Review",
title: "rFlows Budget Review",
desc: "Quarterly review of treasury flows, enoughness thresholds, and overflow routing.",
start: daysFromNow(6, 15, 0), end: daysFromNow(6, 17, 0),
sourceId: communityId, isVirtual: true,
virtualUrl: "https://meet.jit.si/rfunds-review", virtualPlatform: "Jitsi",
virtualUrl: "https://meet.jit.si/rflows-review", virtualPlatform: "Jitsi",
},
{
title: "Cosmolocal Design Sprint",

View File

@ -41,7 +41,7 @@ export function renderLanding(): string {
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128176;</div>
<h3>Revenue Splits</h3>
<p>Creator, community, and provider shares calculated automatically via rFunds. Transparent by default.</p>
<p>Creator, community, and provider shares calculated automatically via rFlows. Transparent by default.</p>
</div>
</div>
</div>
@ -82,7 +82,7 @@ export function renderLanding(): string {
</p>
<ul class="rl-check-list">
<li><strong>Provider matching</strong> &mdash; automatic routing by capability, location, and cost</li>
<li><strong>Revenue splits</strong> &mdash; creator, community, and provider shares via rFunds</li>
<li><strong>Revenue splits</strong> &mdash; creator, community, and provider shares via rFlows</li>
<li><strong>Order tracking</strong> &mdash; real-time status from accepted to delivered</li>
<li><strong>Volume pricing</strong> &mdash; automatic tier detection from pooled orders</li>
</ul>

View File

@ -29,7 +29,7 @@ class FolkAnalyticsView extends HTMLElement {
cookiesSet: 0,
scriptSize: "~2KB",
selfHosted: true,
apps: ["rSpace", "rBooks", "rCart", "rFunds", "rVote", "rWork", "rCal", "rTrips", "rPubs", "rForum", "rInbox", "rMaps", "rTube", "rChoices", "rWallet", "rData", "rNotes"],
apps: ["rSpace", "rBooks", "rCart", "rFlows", "rVote", "rWork", "rCal", "rTrips", "rPubs", "rForum", "rInbox", "rMaps", "rTube", "rChoices", "rWallet", "rData", "rNotes"],
dashboardUrl: "https://analytics.rspace.online",
};
this.render();

View File

@ -17,7 +17,7 @@ const UMAMI_URL = process.env.UMAMI_URL || "https://analytics.rspace.online";
const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || "292f6ac6-79f8-497b-ba6a-7a51e3b87b9f";
const TRACKED_APPS = [
"rSpace", "rNotes", "rVote", "rFunds", "rCart", "rWallet",
"rSpace", "rNotes", "rVote", "rFlows", "rCart", "rWallet",
"rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles",
"rTrips", "rTube", "rWork", "rNetwork", "rData",
];

View File

@ -1,5 +1,5 @@
/**
* rFunds demo client-side WebSocket controller.
* rFlows demo client-side WebSocket controller.
*
* Connects via DemoSync, extracts expenses and budget from shapes,
* renders/updates budget overview, expense list, balances, settlements,

View File

@ -1,178 +1,191 @@
/* ── Funds module theme ───────────────────────────────── */
/* ── Flows module theme ───────────────────────────────── */
/* ── Base ────────────────────────────────────────────── */
.flows-landing, .flows-detail {
font-family: system-ui, -apple-system, sans-serif;
}
/* Thin scrollbars (rApp convention) */
.flows-detail ::-webkit-scrollbar,
.flows-landing ::-webkit-scrollbar { width: 6px; height: 6px; }
.flows-detail ::-webkit-scrollbar-track,
.flows-landing ::-webkit-scrollbar-track { background: transparent; }
.flows-detail ::-webkit-scrollbar-thumb,
.flows-landing ::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
/* ── 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; }
.flows-loading { text-align: center; color: #64748b; padding: 48px 16px; font-size: 14px; }
.flows-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; }
.flows-landing { max-width: 960px; margin: 0 auto; padding: 24px 20px 64px; }
/* Features grid */
.funds-features { margin-bottom: 48px; }
.funds-features__grid {
.flows-features { margin-bottom: 48px; }
.flows-features__grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px;
}
.funds-features__card {
background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 20px;
.flows-features__card {
background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 20px;
transition: border-color 0.2s;
}
.funds-features__card:hover { border-color: #475569; }
.funds-features__icon { font-size: 24px; margin-bottom: 8px; }
.funds-features__card h3 { font-size: 14px; font-weight: 600; color: #e2e8f0; margin: 0 0 6px; }
.funds-features__card p { font-size: 12px; color: #94a3b8; line-height: 1.6; margin: 0; }
.flows-features__card:hover { border-color: #475569; }
.flows-features__icon { font-size: 24px; margin-bottom: 8px; }
.flows-features__card h3 { font-size: 14px; font-weight: 600; color: #e2e8f0; margin: 0 0 6px; }
.flows-features__card p { font-size: 12px; color: #94a3b8; line-height: 1.6; margin: 0; }
/* Flow list */
.funds-flows { margin-bottom: 48px; }
.funds-flows__header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; }
.funds-flows__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0; }
.funds-flows__user { font-size: 12px; color: #64748b; }
.funds-flows__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
.funds-flows__empty {
.flows-flows { margin-bottom: 48px; }
.flows-flows__header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; }
.flows-flows__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0; }
.flows-flows__user { font-size: 12px; color: #64748b; }
.flows-flows__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
.flows-flows__empty {
text-align: center; color: #64748b; padding: 32px 16px; font-size: 14px;
background: #1e293b; border: 1px solid #334155; border-radius: 10px;
background: #1e293b; border: 1px solid #334155; border-radius: 8px;
}
.funds-flows__empty a { color: #6366f1; text-decoration: none; }
.funds-flows__empty a:hover { text-decoration: underline; }
.flows-flows__empty a { color: #6366f1; text-decoration: none; }
.flows-flows__empty a:hover { text-decoration: underline; }
.funds-flow-card {
.flows-flow-card {
display: block; text-decoration: none;
background: #1e293b; border: 1px solid #334155; border-radius: 10px;
background: #1e293b; border: 1px solid #334155; border-radius: 8px;
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; }
.flows-flow-card:hover { border-color: #6366f1; transform: translateY(-1px); }
.flows-flow-card__name { font-size: 15px; font-weight: 600; color: #e2e8f0; margin-bottom: 4px; }
.flows-flow-card__value { font-size: 20px; font-weight: 700; color: #0ea5e9; margin-bottom: 4px; }
.flows-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 20px; }
.flows-about { margin-bottom: 48px; }
.flows-about__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0 0 20px; }
/* Steps layout (replaces the old card grid for "how it works") */
.funds-about__steps { display: flex; flex-direction: column; gap: 16px; }
.funds-about__step {
.flows-about__steps { display: flex; flex-direction: column; gap: 16px; }
.flows-about__step {
display: flex; gap: 16px; align-items: flex-start;
background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 20px;
background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 20px;
}
.funds-about__step-num {
.flows-about__step-num {
width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0;
background: #4f46e5; color: #fff; font-weight: 700; font-size: 14px;
display: flex; align-items: center; justify-content: center;
}
.funds-about__step h3 { font-size: 14px; font-weight: 600; color: #e2e8f0; margin: 0 0 4px; }
.funds-about__step p { font-size: 13px; color: #94a3b8; line-height: 1.6; margin: 0; }
.flows-about__step h3 { font-size: 14px; font-weight: 600; color: #e2e8f0; margin: 0 0 4px; }
.flows-about__step p { font-size: 13px; color: #94a3b8; line-height: 1.6; margin: 0; }
/* Legacy about grid (kept for compat) */
.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;
.flows-about__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
.flows-about__card {
background: #1e293b; border: 1px solid #334155; border-radius: 8px; 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; }
.flows-about__icon { font-size: 28px; margin-bottom: 8px; }
.flows-about__card h3 { font-size: 15px; font-weight: 600; color: #e2e8f0; margin: 0 0 8px; }
.flows-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; }
.flows-detail { max-width: 1100px; margin: 0 auto; padding: 16px 20px 64px; }
/* ── Tabs ────────────────────────────────────────────── */
.funds-tabs {
.flows-tabs {
display: flex; gap: 4px; border-bottom: 1px solid #1e293b; margin-bottom: 20px;
}
.funds-tab {
.flows-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; }
.flows-tab:hover { color: #e2e8f0; }
.flows-tab--active { color: #e2e8f0; border-bottom-color: #6366f1; }
.funds-tab-content { min-height: 300px; }
.flows-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; }
.flows-table { }
.flows-section { margin-bottom: 28px; }
.flows-section__title { font-size: 14px; font-weight: 600; color: #94a3b8; margin: 0 0 12px; text-transform: uppercase; letter-spacing: 0.05em; }
.flows-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;
.flows-card {
background: #1e293b; border: 1px solid #334155; border-radius: 8px; 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; }
.flows-card__header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.flows-card__icon { font-size: 18px; }
.flows-card__label { font-size: 14px; font-weight: 600; color: #e2e8f0; flex: 1; }
.flows-card__type { font-size: 11px; color: #64748b; text-transform: uppercase; }
.flows-card__status { font-size: 11px; font-weight: 600; text-transform: capitalize; }
.flows-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; }
.flows-card__stat { margin-bottom: 10px; }
.flows-card__stat-value { font-size: 18px; font-weight: 700; color: #e2e8f0; }
.flows-card__stat-label { font-size: 12px; color: #64748b; margin-left: 4px; }
.flows-card__stats { display: flex; justify-content: space-between; margin-bottom: 8px; }
/* Progress bar */
.funds-card__bar-container {
.flows-card__bar-container {
position: relative; height: 6px; background: #334155; border-radius: 3px;
margin-bottom: 10px; overflow: visible;
}
.funds-card__bar {
.flows-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 {
.flows-card__bar--outcome { opacity: 0.8; }
.flows-card__bar-threshold {
position: absolute; top: -3px; width: 2px; height: 12px;
background: #fbbf24; border-radius: 1px;
}
.funds-card__thresholds {
.flows-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; }
.flows-card__allocs { margin-top: 8px; padding-top: 8px; border-top: 1px solid #334155; }
.flows-card__alloc-title { font-size: 11px; color: #64748b; font-weight: 600; margin-bottom: 4px; text-transform: uppercase; }
.flows-card__alloc { font-size: 12px; color: #94a3b8; display: flex; align-items: center; gap: 6px; margin: 2px 0; }
.flows-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; }
.flows-status--abundant { color: #fbbf24; }
.flows-status--sufficient { color: #10b981; }
.flows-status--seeking { color: #0ea5e9; }
.flows-status--critical { color: #ef4444; }
/* ── Interactive canvas (Diagram tab) ───────────────── */
.funds-canvas-container {
.flows-canvas-container {
position: relative; height: 70vh; min-height: 400px;
background: #0f172a; border-radius: 12px; border: 1px solid #334155;
overflow: hidden; user-select: none;
overflow: hidden; user-select: none; touch-action: none;
}
.funds-canvas-svg {
.flows-canvas-svg {
width: 100%; height: 100%; display: block;
cursor: grab;
}
.funds-canvas-svg.panning { cursor: grabbing; }
.funds-canvas-svg.dragging { cursor: move; }
.flows-canvas-svg.panning { cursor: grabbing; }
.flows-canvas-svg.dragging { cursor: move; }
/* Toolbar — top-right overlay */
.funds-canvas-toolbar {
.flows-canvas-toolbar {
position: absolute; top: 10px; right: 10px; z-index: 10;
display: flex; gap: 4px; flex-wrap: wrap; align-items: center;
}
.funds-canvas-btn {
.flows-canvas-btn {
padding: 5px 10px; border: 1px solid #475569; border-radius: 6px;
background: #1e293b; color: #e2e8f0; font-size: 11px; font-weight: 500;
cursor: pointer; white-space: nowrap; transition: background 0.15s, border-color 0.15s;
}
.funds-canvas-btn:hover { background: #334155; border-color: #64748b; }
.funds-canvas-btn--source { border-color: #10b981; color: #6ee7b7; }
.funds-canvas-btn--source:hover { background: #064e3b; }
.funds-canvas-btn--funnel { border-color: #3b82f6; color: #93c5fd; }
.funds-canvas-btn--funnel:hover { background: #1e3a5f; }
.funds-canvas-btn--outcome { border-color: #ec4899; color: #f9a8d4; }
.funds-canvas-btn--outcome:hover { background: #4a1942; }
.funds-canvas-btn--active { background: #4f46e5; border-color: #6366f1; color: #fff; }
.funds-canvas-sep {
.flows-canvas-btn:hover { background: #334155; border-color: #64748b; }
.flows-canvas-btn--source { border-color: #10b981; color: #6ee7b7; }
.flows-canvas-btn--source:hover { background: #064e3b; }
.flows-canvas-btn--funnel { border-color: #3b82f6; color: #93c5fd; }
.flows-canvas-btn--funnel:hover { background: #1e3a5f; }
.flows-canvas-btn--outcome { border-color: #ec4899; color: #f9a8d4; }
.flows-canvas-btn--outcome:hover { background: #4a1942; }
.flows-canvas-btn--active { background: #4f46e5; border-color: #6366f1; color: #fff; }
.flows-canvas-sep {
width: 1px; height: 20px; background: #334155; margin: 0 4px;
}
@ -183,14 +196,14 @@
.node-glow { filter: drop-shadow(0 0 6px rgba(251,191,36,0.5)); }
/* Editor panel — right side slide-in */
.funds-editor-panel {
.flows-editor-panel {
position: absolute; top: 0; right: 0; bottom: 0; width: 320px; z-index: 20;
background: #1e293b; border-left: 1px solid #334155;
transform: translateX(100%); transition: transform 0.25s ease;
overflow-y: auto; padding: 16px;
display: flex; flex-direction: column; gap: 12px;
}
.funds-editor-panel.open { transform: translateX(0); }
.flows-editor-panel.open { transform: translateX(0); }
.editor-header {
display: flex; align-items: center; justify-content: space-between;
@ -243,59 +256,59 @@
.edge-pct { color: #e2e8f0; font-weight: 600; min-width: 30px; text-align: center; }
/* Legend — bottom-left */
.funds-canvas-legend {
.flows-canvas-legend {
position: absolute; bottom: 10px; left: 10px; z-index: 10;
display: flex; flex-wrap: wrap; gap: 12px;
font-size: 11px; color: #94a3b8; background: rgba(15,23,42,0.85);
padding: 6px 10px; border-radius: 8px;
}
.funds-canvas-legend-item { display: flex; align-items: center; gap: 4px; }
.funds-canvas-legend-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
.flows-canvas-legend-item { display: flex; align-items: center; gap: 4px; }
.flows-canvas-legend-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
/* Zoom controls — bottom-right */
.funds-canvas-zoom {
.flows-canvas-zoom {
position: absolute; bottom: 10px; right: 10px; z-index: 10;
display: flex; gap: 4px;
}
/* Sufficiency badge — top-left */
.funds-canvas-badge {
.flows-canvas-badge {
position: absolute; top: 10px; left: 10px; z-index: 10;
background: rgba(15,23,42,0.85); border-radius: 10px; padding: 8px 14px;
background: rgba(15,23,42,0.85); border-radius: 8px; padding: 8px 14px;
display: flex; align-items: center; gap: 8px;
}
.funds-canvas-badge__score { font-size: 20px; font-weight: 700; }
.funds-canvas-badge__label { font-size: 10px; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; }
.flows-canvas-badge__score { font-size: 20px; font-weight: 700; }
.flows-canvas-badge__label { font-size: 10px; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; }
/* Legacy diagram (kept for compat) */
.funds-diagram { overflow-x: auto; }
.funds-diagram svg { display: block; margin: 0 auto; }
.funds-diagram__legend {
.flows-diagram { overflow-x: auto; }
.flows-diagram svg { display: block; margin: 0 auto; }
.flows-diagram__legend {
display: flex; flex-wrap: wrap; gap: 16px; justify-content: center;
margin-top: 12px; font-size: 12px; color: #94a3b8;
}
.funds-diagram__legend-item { display: flex; align-items: center; gap: 5px; }
.funds-diagram__dot { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
.flows-diagram__legend-item { display: flex; align-items: center; gap: 5px; }
.flows-diagram__dot { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
/* ── River tab ───────────────────────────────────────── */
.funds-river-container { min-height: 500px; }
.flows-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; }
.flows-tx-list { display: flex; flex-direction: column; gap: 4px; }
.flows-tx-empty { text-align: center; color: #64748b; padding: 48px 16px; font-size: 14px; }
.funds-tx {
.flows-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; }
.flows-tx__icon { font-size: 16px; flex-shrink: 0; }
.flows-tx__body { flex: 1; min-width: 0; }
.flows-tx__desc { font-size: 13px; color: #e2e8f0; font-weight: 500; }
.flows-tx__meta { font-size: 11px; color: #64748b; margin-top: 2px; }
.flows-tx__amount { font-size: 14px; font-weight: 600; white-space: nowrap; }
.flows-tx__amount--positive { color: #10b981; }
.flows-tx__amount--negative { color: #ef4444; }
.flows-tx__time { font-size: 11px; color: #64748b; white-space: nowrap; }
/* ── Port & wiring ──────────────────────────────────── */
.port-group { pointer-events: all; }
@ -312,7 +325,7 @@
stroke-linecap: round; animation: wire-dash 0.6s linear infinite;
}
.funds-canvas-svg.wiring { cursor: crosshair; }
.flows-canvas-svg.wiring { cursor: crosshair; }
@keyframes port-glow {
0%, 100% { filter: drop-shadow(0 0 4px currentColor); }
@ -342,40 +355,40 @@
.satisfaction-bar-fill { transition: width 0.3s ease; }
/* ── Node detail modals ──────────────────────────────── */
.funds-modal-backdrop {
.flows-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 {
.flows-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 {
.flows-modal::-webkit-scrollbar { width: 6px; }
.flows-modal::-webkit-scrollbar-track { background: transparent; }
.flows-modal::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
.flows-modal__header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px;
}
.funds-modal__close {
.flows-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 {
.flows-modal__close:hover { color: #e2e8f0; }
.flows-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; }
.flows-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 { border: 1px solid #334155; border-radius: 8px; 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;
@ -399,7 +412,7 @@
.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;
padding: 14px 8px; border-radius: 8px; border: 2px solid #334155;
background: #0f172a; color: #94a3b8; cursor: pointer; transition: all 0.15s;
font-size: 12px; font-weight: 500;
}
@ -407,14 +420,14 @@
.source-type-btn--active { border-color: #10b981; background: #064e3b; color: #6ee7b7; }
/* Node hover tooltip */
.funds-node-tooltip {
.flows-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; }
.flows-node-tooltip__label { font-weight: 600; margin-bottom: 2px; }
.flows-node-tooltip__stat { color: #94a3b8; font-size: 11px; }
/* Sufficiency glow on funnel status text */
@keyframes sufficiencyPulse {
@ -425,14 +438,17 @@
/* ── Mobile responsive ──────────────────────────────── */
@media (max-width: 768px) {
.funds-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.funds-flows__grid { grid-template-columns: 1fr; }
.funds-features__grid { grid-template-columns: 1fr; }
.funds-cards { grid-template-columns: 1fr; }
.funds-tabs { flex-wrap: wrap; }
.funds-canvas-container { height: 50vh; min-height: 300px; }
.funds-canvas-toolbar { flex-wrap: wrap; gap: 3px; top: 6px; right: 6px; }
.funds-canvas-btn { padding: 4px 7px; font-size: 10px; }
.funds-editor-panel { width: 100%; }
.funds-canvas-legend { font-size: 10px; gap: 8px; }
.flows-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.flows-flows__grid { grid-template-columns: 1fr; }
.flows-features__grid { grid-template-columns: 1fr; }
.flows-cards { grid-template-columns: 1fr; }
.flows-tabs { flex-wrap: wrap; }
.flows-tab { min-height: 44px; min-width: 44px; display: flex; align-items: center; justify-content: center; }
.flows-canvas-container { height: 50vh; min-height: 300px; }
.flows-canvas-toolbar { flex-wrap: wrap; gap: 3px; top: 6px; right: 6px; }
.flows-canvas-btn { padding: 6px 10px; font-size: 11px; min-height: 44px; min-width: 44px; }
.flows-editor-panel { width: 100%; }
.flows-canvas-legend { font-size: 10px; gap: 8px; }
.flows-landing { padding: 16px 12px 48px; }
.flows-detail { padding: 12px 12px 48px; }
}

View File

@ -1,7 +1,7 @@
/**
* <folk-budget-river> animated SVG sankey river visualization.
* <folk-flow-river> animated SVG sankey river visualization.
* Pure renderer: receives nodes via setNodes() or falls back to demo data.
* Parent component (folk-funds-app) handles data fetching and mapping.
* Parent component (folk-flows-app) handles data fetching and mapping.
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "../lib/types";
@ -382,7 +382,7 @@ function esc(s: string): string {
// ─── Web Component ──────────────────────────────────────
class FolkBudgetRiver extends HTMLElement {
class FolkFlowRiver extends HTMLElement {
private shadow: ShadowRoot;
private nodes: FlowNode[] = [];
private simulating = false;
@ -521,4 +521,4 @@ class FolkBudgetRiver extends HTMLElement {
}
}
customElements.define("folk-budget-river", FolkBudgetRiver);
customElements.define("folk-flow-river", FolkFlowRiver);

View File

@ -1,5 +1,5 @@
/**
* <folk-funds-app> main rFunds application component.
* <folk-flows-app> main rFlows application component.
*
* Views:
* "landing" TBFF info hero + flow list cards
@ -56,7 +56,7 @@ function isAuthenticated(): boolean { return getSession() !== null; }
function getAccessToken(): string | null { return getSession()?.accessToken ?? null; }
function getUsername(): string | null { return getSession()?.claims?.username ?? null; }
class FolkFundsApp extends HTMLElement {
class FolkFlowsApp extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private view: View = "landing";
@ -101,6 +101,11 @@ class FolkFundsApp extends HTMLElement {
private wiringPointerX = 0;
private wiringPointerY = 0;
// Touch gesture state (two-finger pinch-to-zoom & pan)
private isTouchPanning = false;
private lastTouchCenter: { x: number; y: number } | null = null;
private lastTouchDist: number | null = null;
// Bound handlers for cleanup
private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null;
private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
@ -132,8 +137,8 @@ class FolkFundsApp extends HTMLElement {
private getApiBase(): string {
const path = window.location.pathname;
// Subdomain: /rfunds/... or Direct: /{space}/rfunds/...
const match = path.match(/^(\/[^/]+)?\/rfunds/);
// Subdomain: /rflows/... or Direct: /{space}/rflows/...
const match = path.match(/^(\/[^/]+)?\/rflows/);
return match ? `${match[0]}` : "";
}
@ -195,9 +200,9 @@ class FolkFundsApp extends HTMLElement {
}
private getCssPath(): string {
// In rSpace: /modules/rfunds/funds.css | Standalone: /modules/rfunds/funds.css
// The shell always serves from /modules/rfunds/ in both modes
return "/modules/rfunds/funds.css";
// In rSpace: /modules/rflows/flows.css | Standalone: /modules/rflows/flows.css
// The shell always serves from /modules/rflows/ in both modes
return "/modules/rflows/flows.css";
}
private render() {
@ -207,8 +212,8 @@ class FolkFundsApp extends HTMLElement {
*, *::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.error ? `<div class="flows-error">${this.esc(this.error)}</div>` : ""}
${this.loading && this.view === "landing" ? '<div class="flows-loading">Loading...</div>' : ""}
${this.renderView()}
`;
this.attachListeners();
@ -222,12 +227,12 @@ class FolkFundsApp extends HTMLElement {
// ─── Landing page ──────────────────────────────────────
private renderLanding(): string {
const demoUrl = this.getApiBase() ? `${this.getApiBase()}/demo` : "/rfunds/demo";
const demoUrl = this.getApiBase() ? `${this.getApiBase()}/demo` : "/rflows/demo";
const authed = isAuthenticated();
const username = getUsername();
return `
<div class="funds-landing">
<div class="flows-landing">
<div class="rapp-nav">
<span class="rapp-nav__title">Flows</span>
<div class="rapp-nav__actions">
@ -239,53 +244,53 @@ class FolkFundsApp extends HTMLElement {
</div>
</div>
<div class="funds-desc" style="color:#94a3b8;font-size:14px;line-height:1.6;max-width:600px;margin-bottom:24px">
<div class="flows-desc" style="color:#94a3b8;font-size:14px;line-height:1.6;max-width:600px;margin-bottom:24px">
Design transparent resource flows with sufficiency-based cascading.
Funnels fill to their threshold, then overflow routes surplus to the next layer &mdash;
ensuring every level has <em style="color:#fbbf24;font-style:normal;font-weight:600">enough</em> before abundance cascades forward.
</div>
<div class="funds-features">
<div class="funds-features__grid">
<div class="funds-features__card">
<div class="funds-features__icon">&#x1F4B0;</div>
<div class="flows-features">
<div class="flows-features__grid">
<div class="flows-features__card">
<div class="flows-features__icon">&#x1F4B0;</div>
<h3>Sources</h3>
<p>Revenue streams split across funnels by configurable allocation percentages.</p>
</div>
<div class="funds-features__card">
<div class="funds-features__icon">&#x1F3DB;</div>
<div class="flows-features__card">
<div class="flows-features__icon">&#x1F3DB;</div>
<h3>Funnels</h3>
<p>Budget buckets with min/max thresholds and sufficiency-based overflow cascading.</p>
</div>
<div class="funds-features__card">
<div class="funds-features__icon">&#x1F3AF;</div>
<div class="flows-features__card">
<div class="flows-features__icon">&#x1F3AF;</div>
<h3>Outcomes</h3>
<p>Funding targets that receive spending allocations. Track progress toward each goal.</p>
</div>
<div class="funds-features__card">
<div class="funds-features__icon">&#x1F30A;</div>
<div class="flows-features__card">
<div class="flows-features__icon">&#x1F30A;</div>
<h3>River View</h3>
<p>Animated sankey diagram showing live fund flows through your entire system.</p>
</div>
<div class="funds-features__card">
<div class="funds-features__icon">&#x2728;</div>
<div class="flows-features__card">
<div class="flows-features__icon">&#x2728;</div>
<h3>Enoughness</h3>
<p>System-wide sufficiency scoring. Golden glow when funnels reach their threshold.</p>
</div>
</div>
</div>
<div class="funds-flows">
<div class="funds-flows__header">
<h2 class="funds-flows__heading">${authed ? `Flows in ${this.esc(this.space)}` : "Your Flows"}</h2>
${authed ? `<span class="funds-flows__user">Signed in as ${this.esc(username || "")}</span>` : ""}
<div class="flows-flows">
<div class="flows-flows__header">
<h2 class="flows-flows__heading">${authed ? `Flows in ${this.esc(this.space)}` : "Your Flows"}</h2>
${authed ? `<span class="flows-flows__user">Signed in as ${this.esc(username || "")}</span>` : ""}
</div>
${this.flows.length > 0 ? `
<div class="funds-flows__grid">
<div class="flows-flows__grid">
${this.flows.map((f) => this.renderFlowCard(f)).join("")}
</div>
` : `
<div class="funds-flows__empty">
<div class="flows-flows__empty">
${authed
? `<p>No flows in this space yet.</p>
<p><a href="${this.esc(demoUrl)}">Explore the demo</a> or create your first flow.</p>`
@ -295,25 +300,25 @@ class FolkFundsApp extends HTMLElement {
`}
</div>
<div class="funds-about">
<h2 class="funds-about__heading">How TBFF Works</h2>
<div class="funds-about__steps">
<div class="funds-about__step">
<div class="funds-about__step-num">1</div>
<div class="flows-about">
<h2 class="flows-about__heading">How TBFF Works</h2>
<div class="flows-about__steps">
<div class="flows-about__step">
<div class="flows-about__step-num">1</div>
<div>
<h3>Define Sources</h3>
<p>Add revenue streams &mdash; grants, donations, sales, or any recurring income &mdash; with allocation splits.</p>
</div>
</div>
<div class="funds-about__step">
<div class="funds-about__step-num">2</div>
<div class="flows-about__step">
<div class="flows-about__step-num">2</div>
<div>
<h3>Configure Funnels</h3>
<p>Set minimum, sufficient, and maximum thresholds. Overflow rules determine where surplus flows.</p>
</div>
</div>
<div class="funds-about__step">
<div class="funds-about__step-num">3</div>
<div class="flows-about__step">
<div class="flows-about__step-num">3</div>
<div>
<h3>Track Outcomes</h3>
<p>Funding targets receive allocations as funnels reach sufficiency. Watch the river flow in real time.</p>
@ -327,14 +332,14 @@ class FolkFundsApp extends HTMLElement {
private renderFlowCard(f: FlowSummary): string {
const detailUrl = this.getApiBase()
? `${this.getApiBase()}/flow/${encodeURIComponent(f.id)}`
: `/rfunds/flow/${encodeURIComponent(f.id)}`;
: `/rflows/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">
<a href="${this.esc(detailUrl)}" class="flows-flow-card" data-flow="${this.esc(f.id)}">
<div class="flows-flow-card__name">${this.esc(f.name || f.label || f.id)}</div>
${value ? `<div class="flows-flow-card__value">${value}</div>` : ""}
<div class="flows-flow-card__meta">
${f.funnelCount != null ? `${f.funnelCount} funnels` : ""}
${f.outcomeCount != null ? ` &middot; ${f.outcomeCount} outcomes` : ""}
${f.status ? ` &middot; ${f.status}` : ""}
@ -347,25 +352,25 @@ class FolkFundsApp extends HTMLElement {
private renderDetail(): string {
const backUrl = this.getApiBase()
? `${this.getApiBase()}/`
: "/rfunds/";
: "/rflows/";
return `
<div class="funds-detail">
<div class="flows-detail">
<div class="rapp-nav">
<a href="${this.esc(backUrl)}" class="rapp-nav__back">&larr; Flows</a>
<span class="rapp-nav__title">${this.esc(this.flowName || "Flow Detail")}</span>
${this.isDemo ? '<span class="rapp-nav__badge">Demo</span>' : ""}
</div>
<div class="funds-tabs">
<button class="funds-tab ${this.tab === "diagram" ? "funds-tab--active" : ""}" data-tab="diagram">Diagram</button>
<button class="funds-tab ${this.tab === "river" ? "funds-tab--active" : ""}" data-tab="river">River</button>
<button class="funds-tab ${this.tab === "table" ? "funds-tab--active" : ""}" data-tab="table">Table</button>
<button class="funds-tab ${this.tab === "transactions" ? "funds-tab--active" : ""}" data-tab="transactions">Transactions</button>
<div class="flows-tabs">
<button class="flows-tab ${this.tab === "diagram" ? "flows-tab--active" : ""}" data-tab="diagram">Diagram</button>
<button class="flows-tab ${this.tab === "river" ? "flows-tab--active" : ""}" data-tab="river">River</button>
<button class="flows-tab ${this.tab === "table" ? "flows-tab--active" : ""}" data-tab="table">Table</button>
<button class="flows-tab ${this.tab === "transactions" ? "flows-tab--active" : ""}" data-tab="transactions">Transactions</button>
</div>
<div class="funds-tab-content">
${this.loading ? '<div class="funds-loading">Loading...</div>' : this.renderTab()}
<div class="flows-tab-content">
${this.loading ? '<div class="flows-loading">Loading...</div>' : this.renderTab()}
</div>
</div>`;
}
@ -385,26 +390,26 @@ class FolkFundsApp extends HTMLElement {
const sources = this.nodes.filter((n) => n.type === "source");
return `
<div class="funds-table">
<div class="flows-table">
${sources.length > 0 ? `
<div class="funds-section">
<h3 class="funds-section__title">Sources</h3>
<div class="funds-cards">
<div class="flows-section">
<h3 class="flows-section__title">Sources</h3>
<div class="flows-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">
<div class="flows-section">
<h3 class="flows-section__title">Funnels</h3>
<div class="flows-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">
<div class="flows-section">
<h3 class="flows-section__title">Outcomes</h3>
<div class="flows-cards">
${outcomes.map((n) => this.renderOutcomeCard(n.data as OutcomeNodeData, n.id)).join("")}
</div>
</div>
@ -414,21 +419,21 @@ class FolkFundsApp extends HTMLElement {
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 class="flows-card">
<div class="flows-card__header">
<span class="flows-card__icon">&#x1F4B0;</span>
<span class="flows-card__label">${this.esc(data.label)}</span>
<span class="flows-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 class="flows-card__stat">
<span class="flows-card__stat-value">$${data.flowRate.toLocaleString()}</span>
<span class="flows-card__stat-label">/month</span>
</div>
${allocations.length > 0 ? `
<div class="funds-card__allocs">
<div class="flows-card__allocs">
${allocations.map((a) => `
<div class="funds-card__alloc">
<span class="funds-card__alloc-dot" style="background:${a.color}"></span>
<div class="flows-card__alloc">
<span class="flows-card__alloc-dot" style="background:${a.color}"></span>
${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
</div>
`).join("")}
@ -443,10 +448,10 @@ class FolkFundsApp extends HTMLElement {
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 statusClass = sufficiency === "abundant" ? "flows-status--abundant"
: sufficiency === "sufficient" ? "flows-status--sufficient"
: data.currentValue < data.minThreshold ? "flows-status--critical"
: "flows-status--seeking";
const statusLabel = sufficiency === "abundant" ? "Abundant"
: sufficiency === "sufficient" ? "Sufficient"
@ -454,48 +459,48 @@ class FolkFundsApp extends HTMLElement {
: "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 class="flows-card">
<div class="flows-card__header">
<span class="flows-card__icon">&#x1F3DB;</span>
<span class="flows-card__label">${this.esc(data.label)}</span>
<span class="flows-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 class="flows-card__bar-container">
<div class="flows-card__bar" style="width:${fillPct}%"></div>
<div class="flows-card__bar-threshold" style="left:${Math.min(100, (threshold / (data.maxCapacity || 1)) * 100)}%"></div>
</div>
<div class="funds-card__stats">
<div class="flows-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>
<span class="flows-card__stat-value">$${Math.floor(data.currentValue).toLocaleString()}</span>
<span class="flows-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>
<span class="flows-card__stat-value">${Math.round(suffPct)}%</span>
<span class="flows-card__stat-label">sufficiency</span>
</div>
</div>
<div class="funds-card__thresholds">
<div class="flows-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>
<div class="flows-card__allocs">
<div class="flows-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>
<div class="flows-card__alloc">
<span class="flows-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>
<div class="flows-card__allocs">
<div class="flows-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>
<div class="flows-card__alloc">
<span class="flows-card__alloc-dot" style="background:${a.color}"></span>
${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
</div>
`).join("")}
@ -514,24 +519,24 @@ class FolkFundsApp extends HTMLElement {
: "#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 class="flows-card">
<div class="flows-card__header">
<span class="flows-card__icon">&#x1F3AF;</span>
<span class="flows-card__label">${this.esc(data.label)}</span>
<span class="flows-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>
${data.description ? `<div class="flows-card__desc">${this.esc(data.description)}</div>` : ""}
<div class="flows-card__bar-container">
<div class="flows-card__bar flows-card__bar--outcome" style="width:${fillPct}%;background:${statusColor}"></div>
</div>
<div class="funds-card__stats">
<div class="flows-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>
<span class="flows-card__stat-value">$${Math.floor(data.fundingReceived).toLocaleString()}</span>
<span class="flows-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>
<span class="flows-card__stat-value">${Math.round(fillPct)}%</span>
<span class="flows-card__stat-label">funded</span>
</div>
</div>
</div>`;
@ -547,7 +552,7 @@ class FolkFundsApp extends HTMLElement {
private renderDiagramTab(): string {
if (this.nodes.length === 0) {
return '<div class="funds-loading">No nodes to display.</div>';
return '<div class="flows-loading">No nodes to display.</div>';
}
const score = computeSystemSufficiency(this.nodes);
@ -555,43 +560,43 @@ class FolkFundsApp extends HTMLElement {
const scoreColor = scorePct >= 90 ? "#fbbf24" : scorePct >= 60 ? "#10b981" : scorePct >= 30 ? "#f59e0b" : "#ef4444";
return `
<div class="funds-canvas-container" id="canvas-container">
<div class="funds-canvas-badge" id="canvas-badge">
<div class="flows-canvas-container" id="canvas-container">
<div class="flows-canvas-badge" id="canvas-badge">
<div>
<div class="funds-canvas-badge__score" id="badge-score" style="color:${scoreColor}">${scorePct}%</div>
<div class="funds-canvas-badge__label">ENOUGH</div>
<div class="flows-canvas-badge__score" id="badge-score" style="color:${scoreColor}">${scorePct}%</div>
<div class="flows-canvas-badge__label">ENOUGH</div>
</div>
</div>
<div class="funds-canvas-toolbar">
<button class="funds-canvas-btn funds-canvas-btn--source" data-canvas-action="add-source">+ Source</button>
<button class="funds-canvas-btn funds-canvas-btn--funnel" data-canvas-action="add-funnel">+ Funnel</button>
<button class="funds-canvas-btn funds-canvas-btn--outcome" data-canvas-action="add-outcome">+ Outcome</button>
<div class="funds-canvas-sep"></div>
<button class="funds-canvas-btn" data-canvas-action="sim" id="sim-btn">${this.isSimulating ? "Pause" : "Play"}</button>
<button class="funds-canvas-btn" data-canvas-action="fit">Fit</button>
<button class="funds-canvas-btn" data-canvas-action="share">Share</button>
<div class="flows-canvas-toolbar">
<button class="flows-canvas-btn flows-canvas-btn--source" data-canvas-action="add-source">+ Source</button>
<button class="flows-canvas-btn flows-canvas-btn--funnel" data-canvas-action="add-funnel">+ Funnel</button>
<button class="flows-canvas-btn flows-canvas-btn--outcome" data-canvas-action="add-outcome">+ Outcome</button>
<div class="flows-canvas-sep"></div>
<button class="flows-canvas-btn" data-canvas-action="sim" id="sim-btn">${this.isSimulating ? "Pause" : "Play"}</button>
<button class="flows-canvas-btn" data-canvas-action="fit">Fit</button>
<button class="flows-canvas-btn" data-canvas-action="share">Share</button>
</div>
<svg class="funds-canvas-svg" id="flow-canvas">
<svg class="flows-canvas-svg" id="flow-canvas">
<g id="canvas-transform">
<g id="edge-layer"></g>
<g id="wire-layer"></g>
<g id="node-layer"></g>
</g>
</svg>
<div class="funds-editor-panel" id="editor-panel"></div>
<div class="funds-canvas-legend">
<span class="funds-canvas-legend-item"><span class="funds-canvas-legend-dot" style="background:#10b981"></span>Source</span>
<span class="funds-canvas-legend-item"><span class="funds-canvas-legend-dot" style="background:#0ea5e9"></span>Funnel</span>
<span class="funds-canvas-legend-item"><span class="funds-canvas-legend-dot" style="background:#f59e0b"></span>Overflow</span>
<span class="funds-canvas-legend-item"><span class="funds-canvas-legend-dot" style="background:#8b5cf6"></span>Spending</span>
<span class="funds-canvas-legend-item"><span class="funds-canvas-legend-dot" style="background:#3b82f6"></span>Outcome</span>
<span class="funds-canvas-legend-item"><span class="funds-canvas-legend-dot" style="background:#fbbf24"></span>Sufficient</span>
<div class="flows-editor-panel" id="editor-panel"></div>
<div class="flows-canvas-legend">
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#10b981"></span>Source</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#0ea5e9"></span>Funnel</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#f59e0b"></span>Overflow</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#8b5cf6"></span>Spending</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#3b82f6"></span>Outcome</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#fbbf24"></span>Sufficient</span>
</div>
<div class="funds-canvas-zoom">
<button class="funds-canvas-btn" data-canvas-action="zoom-in">+</button>
<button class="funds-canvas-btn" data-canvas-action="zoom-out">&minus;</button>
<div class="flows-canvas-zoom">
<button class="flows-canvas-btn" data-canvas-action="zoom-in">+</button>
<button class="flows-canvas-btn" data-canvas-action="zoom-out">&minus;</button>
</div>
<div class="funds-node-tooltip" id="node-tooltip" style="display:none"></div>
<div class="flows-node-tooltip" id="node-tooltip" style="display:none"></div>
</div>`;
}
@ -896,6 +901,72 @@ class FolkFundsApp extends HTMLElement {
});
}
// Touch gesture handling for two-finger pan + pinch-to-zoom
const getTouchCenter = (touches: TouchList) => ({
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2,
});
const getTouchDist = (touches: TouchList) => {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.hypot(dx, dy);
};
svg.addEventListener("touchstart", (e: TouchEvent) => {
if (e.touches.length === 2) {
e.preventDefault();
this.isTouchPanning = true;
// Cancel any pointer-based pan or node drag
this.isPanning = false;
if (this.draggingNodeId) {
this.draggingNodeId = null;
nodeDragStarted = false;
svg.classList.remove("dragging");
}
if (this.wiringActive) this.cancelWiring();
this.lastTouchCenter = getTouchCenter(e.touches);
this.lastTouchDist = getTouchDist(e.touches);
}
}, { passive: false });
svg.addEventListener("touchmove", (e: TouchEvent) => {
if (e.touches.length === 2 && this.isTouchPanning) {
e.preventDefault();
const currentCenter = getTouchCenter(e.touches);
const currentDist = getTouchDist(e.touches);
if (this.lastTouchCenter) {
// Two-finger pan
this.canvasPanX += currentCenter.x - this.lastTouchCenter.x;
this.canvasPanY += currentCenter.y - this.lastTouchCenter.y;
}
if (this.lastTouchDist && this.lastTouchDist > 0) {
// Pinch-to-zoom around gesture center
const zoomDelta = currentDist / this.lastTouchDist;
const newZoom = Math.max(0.2, Math.min(5, this.canvasZoom * zoomDelta));
const rect = svg.getBoundingClientRect();
const cx = currentCenter.x - rect.left;
const cy = currentCenter.y - rect.top;
this.canvasPanX = cx - (cx - this.canvasPanX) * (newZoom / this.canvasZoom);
this.canvasPanY = cy - (cy - this.canvasPanY) * (newZoom / this.canvasZoom);
this.canvasZoom = newZoom;
}
this.lastTouchCenter = currentCenter;
this.lastTouchDist = currentDist;
this.updateCanvasTransform();
}
}, { passive: false });
svg.addEventListener("touchend", (e: TouchEvent) => {
if (e.touches.length < 2) {
this.lastTouchCenter = null;
this.lastTouchDist = null;
this.isTouchPanning = false;
}
});
// Keyboard
this._boundKeyDown = (e: KeyboardEvent) => {
// Skip if typing in editor input
@ -1665,7 +1736,7 @@ class FolkFundsApp extends HTMLElement {
let apiKey = "STAGING_KEY";
let env = "STAGING";
try {
const base = this.space ? `/s/${this.space}/rfunds` : "/rfunds";
const base = this.space ? `/s/${this.space}/rflows` : "/rflows";
const res = await fetch(`${base}/api/transak/config`);
if (res.ok) {
const cfg = await res.json();
@ -1762,20 +1833,20 @@ class FolkFundsApp extends HTMLElement {
const node = this.nodes.find((n) => n.id === nodeId);
if (!node) return;
let html = `<div class="funds-node-tooltip__label">${this.esc((node.data as any).label)}</div>`;
let html = `<div class="flows-node-tooltip__label">${this.esc((node.data as any).label)}</div>`;
if (node.type === "source") {
const d = node.data as SourceNodeData;
html += `<div class="funds-node-tooltip__stat">$${d.flowRate.toLocaleString()}/mo &middot; ${d.sourceType}</div>`;
html += `<div class="flows-node-tooltip__stat">$${d.flowRate.toLocaleString()}/mo &middot; ${d.sourceType}</div>`;
} else if (node.type === "funnel") {
const d = node.data as FunnelNodeData;
const suf = computeSufficiencyState(d);
html += `<div class="funds-node-tooltip__stat">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.sufficientThreshold ?? d.maxThreshold).toLocaleString()}</div>`;
html += `<div class="funds-node-tooltip__stat" style="text-transform:capitalize;color:${suf === "sufficient" || suf === "abundant" ? "#fbbf24" : "#94a3b8"}">${suf}</div>`;
html += `<div class="flows-node-tooltip__stat">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.sufficientThreshold ?? d.maxThreshold).toLocaleString()}</div>`;
html += `<div class="flows-node-tooltip__stat" style="text-transform:capitalize;color:${suf === "sufficient" || suf === "abundant" ? "#fbbf24" : "#94a3b8"}">${suf}</div>`;
} else {
const d = node.data as OutcomeNodeData;
const pct = d.fundingTarget > 0 ? Math.round((d.fundingReceived / d.fundingTarget) * 100) : 0;
html += `<div class="funds-node-tooltip__stat">$${Math.floor(d.fundingReceived).toLocaleString()} / $${Math.floor(d.fundingTarget).toLocaleString()} (${pct}%)</div>`;
html += `<div class="funds-node-tooltip__stat" style="text-transform:capitalize">${d.status}</div>`;
html += `<div class="flows-node-tooltip__stat">$${Math.floor(d.fundingReceived).toLocaleString()} / $${Math.floor(d.fundingTarget).toLocaleString()} (${pct}%)</div>`;
html += `<div class="flows-node-tooltip__stat" style="text-transform:capitalize">${d.status}</div>`;
}
tooltip.innerHTML = html;
@ -1811,7 +1882,7 @@ class FolkFundsApp extends HTMLElement {
// ─── Node detail modals ──────────────────────────────
private closeModal() {
const m = this.shadow.getElementById("funds-modal");
const m = this.shadow.getElementById("flows-modal");
if (m) m.remove();
}
@ -1858,8 +1929,8 @@ class FolkFundsApp extends HTMLElement {
<span>${Math.min(phasePct, 100)}% funded</span>
<span>$${Math.floor(Math.min(d.fundingReceived, p.fundingThreshold)).toLocaleString()} / $${p.fundingThreshold.toLocaleString()}</span>
</div>
<div class="funds-modal__progress-bar">
<div class="funds-modal__progress-fill" style="width:${Math.min(phasePct, 100)}%;background:${unlocked ? "#10b981" : "#3b82f6"}"></div>
<div class="flows-modal__progress-bar">
<div class="flows-modal__progress-fill" style="width:${Math.min(phasePct, 100)}%;background:${unlocked ? "#10b981" : "#3b82f6"}"></div>
</div>
</div>
${p.tasks.map((t, ti) => `
@ -1877,22 +1948,22 @@ class FolkFundsApp extends HTMLElement {
}
const backdrop = document.createElement("div");
backdrop.className = "funds-modal-backdrop";
backdrop.id = "funds-modal";
backdrop.innerHTML = `<div class="funds-modal">
<div class="funds-modal__header">
backdrop.className = "flows-modal-backdrop";
backdrop.id = "flows-modal";
backdrop.innerHTML = `<div class="flows-modal">
<div class="flows-modal__header">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;background:${statusColor}22;color:${statusColor}">${statusLabel}</span>
<span style="font-size:16px;font-weight:700;color:#e2e8f0">${this.esc(d.label)}</span>
</div>
<button class="funds-modal__close" data-modal-action="close">&times;</button>
<button class="flows-modal__close" data-modal-action="close">&times;</button>
</div>
${d.description ? `<div style="font-size:13px;color:#94a3b8;line-height:1.6;margin-bottom:16px;padding:10px 12px;background:#0f172a;border-radius:8px;border-left:3px solid ${statusColor}">${this.esc(d.description)}</div>` : ""}
<div style="margin-bottom:20px">
<div style="font-size:28px;font-weight:700;color:#e2e8f0">$${Math.floor(d.fundingReceived).toLocaleString()}</div>
<div style="font-size:13px;color:#64748b;margin-top:2px">of $${Math.floor(d.fundingTarget).toLocaleString()} (${Math.round(fillPct)}%)</div>
<div class="funds-modal__progress-bar" style="margin-top:10px;height:10px">
<div class="funds-modal__progress-fill" style="width:${fillPct}%;background:${statusColor}"></div>
<div class="flows-modal__progress-bar" style="margin-top:10px;height:10px">
<div class="flows-modal__progress-fill" style="width:${fillPct}%;background:${statusColor}"></div>
</div>
</div>
${d.phases && d.phases.length > 0 ? `<div style="margin-bottom:16px">
@ -2018,15 +2089,15 @@ class FolkFundsApp extends HTMLElement {
}
const backdrop = document.createElement("div");
backdrop.className = "funds-modal-backdrop";
backdrop.id = "funds-modal";
backdrop.innerHTML = `<div class="funds-modal">
<div class="funds-modal__header">
backdrop.className = "flows-modal-backdrop";
backdrop.id = "flows-modal";
backdrop.innerHTML = `<div class="flows-modal">
<div class="flows-modal__header">
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:20px">${icons[d.sourceType] || "&#x1F4B0;"}</span>
<span style="font-size:16px;font-weight:700;color:#e2e8f0">${this.esc(d.label)}</span>
</div>
<button class="funds-modal__close" data-modal-action="close">&times;</button>
<button class="flows-modal__close" data-modal-action="close">&times;</button>
</div>
<div style="margin-bottom:16px">
<div style="font-size:11px;font-weight:600;color:#94a3b8;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px">Source Type</div>
@ -2258,7 +2329,7 @@ class FolkFundsApp extends HTMLElement {
// ─── River tab ────────────────────────────────────────
private renderRiverTab(): string {
return `<div class="funds-river-container" id="river-mount"></div>`;
return `<div class="flows-river-container" id="river-mount"></div>`;
}
private mountRiver() {
@ -2266,9 +2337,9 @@ class FolkFundsApp extends HTMLElement {
if (!mount) return;
// Check if already mounted
if (mount.querySelector("folk-budget-river")) return;
if (mount.querySelector("folk-flow-river")) return;
const river = document.createElement("folk-budget-river") as any;
const river = document.createElement("folk-flow-river") as any;
river.setAttribute("simulate", "true");
mount.appendChild(river);
@ -2285,38 +2356,38 @@ class FolkFundsApp extends HTMLElement {
private renderTransactionsTab(): string {
if (this.isDemo) {
return `
<div class="funds-tx-empty">
<div class="flows-tx-empty">
<p>Transaction history is not available in demo mode.</p>
</div>`;
}
if (!this.txLoaded) {
return '<div class="funds-loading">Loading transactions...</div>';
return '<div class="flows-loading">Loading transactions...</div>';
}
if (this.transactions.length === 0) {
return `
<div class="funds-tx-empty">
<div class="flows-tx-empty">
<p>No transactions yet for this flow.</p>
</div>`;
}
return `
<div class="funds-tx-list">
<div class="flows-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">
<div class="flows-tx">
<div class="flows-tx__icon">${tx.type === "deposit" ? "&#x2B06;" : tx.type === "withdraw" ? "&#x2B07;" : "&#x1F504;"}</div>
<div class="flows-tx__body">
<div class="flows-tx__desc">${this.esc(tx.description || tx.type)}</div>
<div class="flows-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"}">
<div class="flows-tx__amount ${tx.type === "deposit" ? "flows-tx__amount--positive" : "flows-tx__amount--negative"}">
${tx.type === "deposit" ? "+" : "-"}$${Math.abs(tx.amount).toLocaleString()}
</div>
<div class="funds-tx__time">${this.formatTime(tx.timestamp)}</div>
<div class="flows-tx__time">${this.formatTime(tx.timestamp)}</div>
</div>
`).join("")}
</div>`;
@ -2415,7 +2486,7 @@ class FolkFundsApp extends HTMLElement {
if (flowId) {
const detailUrl = this.getApiBase()
? `${this.getApiBase()}/flow/${encodeURIComponent(flowId)}`
: `/rfunds/flow/${encodeURIComponent(flowId)}`;
: `/rflows/flow/${encodeURIComponent(flowId)}`;
window.location.href = detailUrl;
return;
}
@ -2439,4 +2510,4 @@ class FolkFundsApp extends HTMLElement {
}
}
customElements.define("folk-funds-app", FolkFundsApp);
customElements.define("folk-flows-app", FolkFlowsApp);

View File

@ -1,6 +1,6 @@
/**
* rFunds rich landing page body.
* Ported from rfunds-online/app/page.tsx (Next.js/Tailwind).
* rFlows rich landing page body.
* Ported from rflows-online/app/page.tsx (Next.js/Tailwind).
* Returned by landingPage() in the module export;
* the shell wraps it with header, CSS, and analytics.
*/
@ -8,7 +8,7 @@ export function renderLanding(): string {
return `
<!-- Hero -->
<div class="rl-hero">
<span class="rl-tagline">rFunds</span>
<span class="rl-tagline">rFlows</span>
<h1 class="rl-heading" style="background:linear-gradient(to right,#fcd34d,#6ee7b7,#93c5fd);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Threshold-Based Flow Funding
</h1>
@ -17,7 +17,7 @@ export function renderLanding(): string {
Connect funnels, set overflow rules, and track outcomes in real-time.
</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rfunds" class="rl-cta-secondary" id="ml-primary">Try the Demo</a>
<a href="https://demo.rspace.online/rflows" class="rl-cta-secondary" id="ml-primary">Try the Demo</a>
<a href="/create-space" class="rl-cta-primary">Create Your Own</a>
</div>
</div>
@ -120,7 +120,7 @@ export function renderLanding(): string {
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Ecosystem Integration</h2>
<p class="rl-subtext" style="text-align:center">
rFunds connects to other rSpace modules for end-to-end treasury governance.
rFlows connects to other rSpace modules for end-to-end treasury governance.
</p>
<div class="rl-grid-2" style="max-width:700px;margin:0 auto">
<div class="rl-integration">
@ -145,7 +145,7 @@ export function renderLanding(): string {
<section class="rl-section rl-section--alt">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
<p class="rl-subtext" style="text-align:center">The libraries and tools that power rFunds.</p>
<p class="rl-subtext" style="text-align:center">The libraries and tools that power rFlows.</p>
<div class="rl-grid-3">
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#127754;</div>
@ -170,7 +170,7 @@ export function renderLanding(): string {
<section class="rl-section">
<div class="rl-container" style="text-align:center">
<h2 class="rl-heading">Your Data, Protected</h2>
<p class="rl-subtext">How rFunds keeps your information safe.</p>
<p class="rl-subtext">How rFlows keeps your information safe.</p>
<div class="rl-grid-3">
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128274;</div>
@ -199,7 +199,7 @@ export function renderLanding(): string {
<h2 class="rl-heading">Ready to design your funding flows?</h2>
<p class="rl-subtext">Start with the interactive demo or create your own space with custom funnels and connections.</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rfunds" class="rl-cta-primary">Try the Demo</a>
<a href="https://demo.rspace.online/rflows" class="rl-cta-primary">Try the Demo</a>
<a href="/create-space" class="rl-cta-secondary">Create Your Own</a>
</div>
</div>

View File

@ -1,6 +1,6 @@
/**
* Maps TBFF API response data to FlowNode[] for visualization.
* Shared between folk-funds-app (data loading) and folk-budget-river (rendering).
* Shared between folk-flows-app (data loading) and folk-flow-river (rendering).
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from "./types";

View File

@ -1,5 +1,5 @@
/**
* Demo presets ported from rfunds-online/lib/presets.ts.
* Demo presets ported from rflows-online/lib/presets.ts.
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData } from "./types";

View File

@ -1,6 +1,6 @@
/**
* Flow simulation engine pure function, no framework dependencies.
* Ported from rfunds-online/lib/simulation.ts.
* Ported from rflows-online/lib/simulation.ts.
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SufficiencyState } from "./types";

View File

@ -1,5 +1,5 @@
/**
* Flow types framework-agnostic (ported from rfunds-online, @xyflow removed).
* Flow types framework-agnostic (ported from rflows-online, @xyflow removed).
*/
export interface IntegrationSource {

View File

@ -1,5 +1,5 @@
/**
* rFunds Local-First Client
* rFlows Local-First Client
*
* Wraps the shared local-first stack for space-flow associations.
* Actual flow logic stays in the external payment-flow service.
@ -11,10 +11,10 @@ import type { DocumentId } from '../../shared/local-first/document';
import { EncryptedDocStore } from '../../shared/local-first/storage';
import { DocSyncManager } from '../../shared/local-first/sync';
import { DocCrypto } from '../../shared/local-first/crypto';
import { fundsSchema, fundsDocId } from './schemas';
import type { FundsDoc, SpaceFlow } from './schemas';
import { flowsSchema, flowsDocId } from './schemas';
import type { FlowsDoc, SpaceFlow } from './schemas';
export class FundsLocalFirstClient {
export class FlowsLocalFirstClient {
#space: string;
#documents: DocumentManager;
#store: EncryptedDocStore;
@ -29,7 +29,7 @@ export class FundsLocalFirstClient {
documents: this.#documents,
store: this.#store,
});
this.#documents.registerSchema(fundsSchema);
this.#documents.registerSchema(flowsSchema);
}
get isConnected(): boolean { return this.#sync.isConnected; }
@ -37,53 +37,53 @@ export class FundsLocalFirstClient {
async init(): Promise<void> {
if (this.#initialized) return;
await this.#store.open();
const cachedIds = await this.#store.listByModule('funds', 'flows');
const cachedIds = await this.#store.listByModule('flows', 'data');
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<FundsDoc>(docId, fundsSchema, binary);
this.#documents.open<FlowsDoc>(docId, flowsSchema, binary);
}
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[FundsClient] Working offline'); }
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[FlowsClient] Working offline'); }
this.#initialized = true;
}
async subscribe(): Promise<FundsDoc | null> {
const docId = fundsDocId(this.#space) as DocumentId;
let doc = this.#documents.get<FundsDoc>(docId);
async subscribe(): Promise<FlowsDoc | null> {
const docId = flowsDocId(this.#space) as DocumentId;
let doc = this.#documents.get<FlowsDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<FundsDoc>(docId, fundsSchema, binary)
: this.#documents.open<FundsDoc>(docId, fundsSchema);
? this.#documents.open<FlowsDoc>(docId, flowsSchema, binary)
: this.#documents.open<FlowsDoc>(docId, flowsSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getFlows(): FundsDoc | undefined {
return this.#documents.get<FundsDoc>(fundsDocId(this.#space) as DocumentId);
getFlows(): FlowsDoc | undefined {
return this.#documents.get<FlowsDoc>(flowsDocId(this.#space) as DocumentId);
}
addSpaceFlow(flow: SpaceFlow): void {
const docId = fundsDocId(this.#space) as DocumentId;
this.#sync.change<FundsDoc>(docId, `Add flow ${flow.flowId}`, (d) => {
const docId = flowsDocId(this.#space) as DocumentId;
this.#sync.change<FlowsDoc>(docId, `Add flow ${flow.flowId}`, (d) => {
d.spaceFlows[flow.id] = flow;
});
}
removeSpaceFlow(flowId: string): void {
const docId = fundsDocId(this.#space) as DocumentId;
this.#sync.change<FundsDoc>(docId, `Remove flow ${flowId}`, (d) => {
const docId = flowsDocId(this.#space) as DocumentId;
this.#sync.change<FlowsDoc>(docId, `Remove flow ${flowId}`, (d) => {
for (const [id, sf] of Object.entries(d.spaceFlows)) {
if (sf.flowId === flowId) delete d.spaceFlows[id];
}
});
}
onChange(cb: (doc: FundsDoc) => void): () => void {
return this.#sync.onChange(fundsDocId(this.#space) as DocumentId, cb as (doc: any) => void);
onChange(cb: (doc: FlowsDoc) => void): () => void {
return this.#sync.onChange(flowsDocId(this.#space) as DocumentId, cb as (doc: any) => void);
}
onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); }

View File

@ -1,7 +1,7 @@
/**
* Funds module budget flows, river visualization, and treasury management.
* Flows module budget flows, river visualization, and treasury management.
*
* Proxies flow-service API calls and serves the BudgetRiver visualization.
* Proxies flow-service API calls and serves the FlowRiver visualization.
*/
import { Hono } from "hono";
@ -12,18 +12,18 @@ import { getModuleInfoList } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server';
import { fundsSchema, fundsDocId, type FundsDoc, type SpaceFlow } from './schemas';
import { flowsSchema, flowsDocId, type FlowsDoc, type SpaceFlow } from './schemas';
let _syncServer: SyncServer | null = null;
const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010";
function ensureDoc(space: string): FundsDoc {
const docId = fundsDocId(space);
let doc = _syncServer!.getDoc<FundsDoc>(docId);
function ensureDoc(space: string): FlowsDoc {
const docId = flowsDocId(space);
let doc = _syncServer!.getDoc<FlowsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<FundsDoc>(), 'init', (d) => {
const init = fundsSchema.init();
doc = Automerge.change(Automerge.init<FlowsDoc>(), 'init', (d) => {
const init = flowsSchema.init();
d.meta = init.meta;
d.meta.spaceSlug = space;
d.spaceFlows = {};
@ -224,9 +224,9 @@ routes.post("/api/space-flows", async (c) => {
const { space, flowId } = await c.req.json();
if (!space || !flowId) return c.json({ error: "space and flowId required" }, 400);
const docId = fundsDocId(space);
const docId = flowsDocId(space);
ensureDoc(space);
_syncServer!.changeDoc<FundsDoc>(docId, 'add space flow', (d) => {
_syncServer!.changeDoc<FlowsDoc>(docId, 'add space flow', (d) => {
const key = `${space}:${flowId}`;
if (!d.spaceFlows[key]) {
d.spaceFlows[key] = { id: key, spaceSlug: space, flowId, addedBy: claims.sub, createdAt: Date.now() };
@ -245,12 +245,12 @@ routes.delete("/api/space-flows/:flowId", async (c) => {
const space = c.req.query("space") || "";
if (!space) return c.json({ error: "space query param required" }, 400);
const docId = fundsDocId(space);
const doc = _syncServer!.getDoc<FundsDoc>(docId);
const docId = flowsDocId(space);
const doc = _syncServer!.getDoc<FlowsDoc>(docId);
if (doc) {
const key = `${space}:${flowId}`;
if (doc.spaceFlows[key]) {
_syncServer!.changeDoc<FundsDoc>(docId, 'remove space flow', (d) => {
_syncServer!.changeDoc<FlowsDoc>(docId, 'remove space flow', (d) => {
delete d.spaceFlows[key];
});
}
@ -260,25 +260,25 @@ routes.delete("/api/space-flows/:flowId", async (c) => {
// ─── Page routes ────────────────────────────────────────
const fundsScripts = `
const flowsScripts = `
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js"></script>
<script type="module" src="/modules/rfunds/folk-funds-app.js"></script>
<script type="module" src="/modules/rfunds/folk-budget-river.js"></script>`;
<script type="module" src="/modules/rflows/folk-flows-app.js"></script>
<script type="module" src="/modules/rflows/folk-flow-river.js"></script>`;
const fundsStyles = `<link rel="stylesheet" href="/modules/rfunds/funds.css">`;
const flowsStyles = `<link rel="stylesheet" href="/modules/rflows/flows.css">`;
// Landing page (also serves demo via centralized /demo → space="demo" rewrite)
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${spaceSlug} — Funds | rSpace`,
moduleId: "rfunds",
title: `${spaceSlug} — Flows | rSpace`,
moduleId: "rflows",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-funds-app space="${spaceSlug}"${spaceSlug === "demo" ? ' mode="demo"' : ''}></folk-funds-app>`,
scripts: fundsScripts,
styles: fundsStyles,
body: `<folk-flows-app space="${spaceSlug}"${spaceSlug === "demo" ? ' mode="demo"' : ''}></folk-flows-app>`,
scripts: flowsScripts,
styles: flowsStyles,
}));
});
@ -287,54 +287,54 @@ 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: "rfunds",
title: `Flow — rFlows | rSpace`,
moduleId: "rflows",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
styles: fundsStyles,
body: `<folk-funds-app space="${spaceSlug}" flow-id="${flowId}"></folk-funds-app>`,
scripts: fundsScripts,
styles: flowsStyles,
body: `<folk-flows-app space="${spaceSlug}" flow-id="${flowId}"></folk-flows-app>`,
scripts: flowsScripts,
}));
});
// ── Seed template data ──
function seedTemplateFunds(space: string) {
function seedTemplateFlows(space: string) {
if (!_syncServer) return;
const doc = ensureDoc(space);
if (Object.keys(doc.spaceFlows).length > 0) return;
const docId = fundsDocId(space);
const docId = flowsDocId(space);
const now = Date.now();
const flowId = crypto.randomUUID();
// Create a SpaceFlow entry pointing to "demo" — the frontend
// already renders demoNodes from presets.ts in demo mode.
_syncServer.changeDoc<FundsDoc>(docId, 'seed template flow', (d) => {
_syncServer.changeDoc<FlowsDoc>(docId, 'seed template flow', (d) => {
d.spaceFlows[flowId] = {
id: flowId, spaceSlug: space, flowId: 'demo',
addedBy: 'did:demo:seed', createdAt: now,
};
});
console.log(`[Funds] Template seeded for "${space}": 1 demo flow association`);
console.log(`[Flows] Template seeded for "${space}": 1 demo flow association`);
}
export const fundsModule: RSpaceModule = {
id: "rfunds",
name: "rFunds",
export const flowsModule: RSpaceModule = {
id: "rflows",
name: "rFlows",
icon: "🌊",
description: "Budget flows, river visualization, and treasury management",
scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [{ pattern: '{space}:funds:flows', description: 'Space flow associations', init: fundsSchema.init }],
docSchemas: [{ pattern: '{space}:flows:data', description: 'Space flow associations', init: flowsSchema.init }],
routes,
landingPage: renderLanding,
seedTemplate: seedTemplateFunds,
seedTemplate: seedTemplateFlows,
async onInit(ctx) {
_syncServer = ctx.syncServer;
},
standaloneDomain: "rfunds.online",
standaloneDomain: "rflows.online",
feeds: [
{
id: "treasury-flows",

View File

@ -1,8 +1,8 @@
/**
* rFunds Automerge document schemas.
* rFlows Automerge document schemas.
*
* Granularity: one Automerge document per space (flow associations).
* DocId format: {space}:funds:flows
* DocId format: {space}:flows:data
*
* Actual flow logic stays in the external payment-flow service.
* This doc tracks which flows are associated with which spaces.
@ -20,7 +20,7 @@ export interface SpaceFlow {
createdAt: number;
}
export interface FundsDoc {
export interface FlowsDoc {
meta: {
module: string;
collection: string;
@ -33,14 +33,14 @@ export interface FundsDoc {
// ── Schema registration ──
export const fundsSchema: DocSchema<FundsDoc> = {
module: 'funds',
collection: 'flows',
export const flowsSchema: DocSchema<FlowsDoc> = {
module: 'flows',
collection: 'data',
version: 1,
init: (): FundsDoc => ({
init: (): FlowsDoc => ({
meta: {
module: 'funds',
collection: 'flows',
module: 'flows',
collection: 'data',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
@ -51,6 +51,6 @@ export const fundsSchema: DocSchema<FundsDoc> = {
// ── Helpers ──
export function fundsDocId(space: string) {
return `${space}:funds:flows` as const;
export function flowsDocId(space: string) {
return `${space}:flows:data` as const;
}

View File

@ -157,7 +157,7 @@ class FolkNotesApp extends HTMLElement {
Accommodation: EUR 1200 (30%)
Activities: EUR 1000 (25%)
Food: EUR 600 (15%)
Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rFunds. Current spend: EUR 1,203.</em></p>`,
Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rFlows. Current spend: EUR 1,203.</em></p>`,
content_plain: "Pre-trip preparation checklist covering flights, transfers, travel documents, and budget overview for the Alpine Explorer 2026 trip.",
content_format: 'html',
type: "NOTE", tags: ["planning", "budget", "transport"], is_pinned: true,

View File

@ -171,7 +171,7 @@ function seedDemoIfEmpty(space: string) {
},
{
nbId: nb1Id, nbDocId: nb1DocId, title: "Revenue Sharing Model",
content: "## Current Split\n\n| Recipient | Share | Rationale |\n|-----------|-------|-----------|\n| Provider | 50% | Covers materials, labor, shipping |\n| Creator | 35% | Design and creative work |\n| Community | 15% | Platform maintenance, commons fund |\n\n## Enoughness Thresholds\n\nOnce a funnel reaches its sufficient threshold, surplus flows to the next highest-need funnel. This prevents accumulation and keeps resources flowing.\n\n## Implementation\n\nrFunds Flow Service handles deposits from rCart. Each order total is routed through the configured flow → funnel → overflow splits.",
content: "## Current Split\n\n| Recipient | Share | Rationale |\n|-----------|-------|-----------|\n| Provider | 50% | Covers materials, labor, shipping |\n| Creator | 35% | Design and creative work |\n| Community | 15% | Platform maintenance, commons fund |\n\n## Enoughness Thresholds\n\nOnce a funnel reaches its sufficient threshold, surplus flows to the next highest-need funnel. This prevents accumulation and keeps resources flowing.\n\n## Implementation\n\nrFlows Flow Service handles deposits from rCart. Each order total is routed through the configured flow → funnel → overflow splits.",
tags: ["cosmolocal", "governance"],
},
{
@ -181,7 +181,7 @@ function seedDemoIfEmpty(space: string) {
},
{
nbId: nb2Id, nbDocId: nb2DocId, title: "Weekly Standup — Feb 15, 2026",
content: "## Attendees\n\nAlice, Bob, Carol\n\n## Updates\n\n**Alice**: Finished EncryptID guardian recovery flow. 2-of-3 guardian approval working. Next: device linking via QR code.\n\n**Bob**: Provider registry now has 6 printers globally. Working on proximity search with earthdistance extension.\n\n**Carol**: rFunds river visualization deployed. Enoughness layer showing golden glow on sufficient funnels.\n\n## Action Items\n\n- [ ] Alice: Document guardian recovery API endpoints\n- [ ] Bob: Add turnaround time estimates to provider matching\n- [ ] Carol: Add demo mode to river view with mock data",
content: "## Attendees\n\nAlice, Bob, Carol\n\n## Updates\n\n**Alice**: Finished EncryptID guardian recovery flow. 2-of-3 guardian approval working. Next: device linking via QR code.\n\n**Bob**: Provider registry now has 6 printers globally. Working on proximity search with earthdistance extension.\n\n**Carol**: rFlows river visualization deployed. Enoughness layer showing golden glow on sufficient funnels.\n\n## Action Items\n\n- [ ] Alice: Document guardian recovery API endpoints\n- [ ] Bob: Add turnaround time estimates to provider matching\n- [ ] Carol: Add demo mode to river view with mock data",
tags: ["standup"],
},
{

View File

@ -483,13 +483,13 @@ function seedTemplateTrips(space: string) {
d.destinations[dest2Id] = {
id: dest2Id, tripId, name: 'Athens', country: 'Greece',
lat: 37.9838, lng: 23.7275, arrivalDate: '2026-04-16', departureDate: '2026-04-18',
notes: 'Commons Fest workshop — present rFunds river visualization.', sortOrder: 1, createdAt: now,
notes: 'Commons Fest workshop — present rFlows river visualization.', sortOrder: 1, createdAt: now,
};
d.itinerary = {};
const itin = [
{ destId: dest1Id, title: 'Maker Space Visit', category: 'meeting', date: '2026-04-14', start: '10:00', end: '13:00', notes: 'Tour facilities, discuss print capabilities' },
{ destId: dest1Id, title: 'Prototype Session', category: 'workshop', date: '2026-04-15', start: '09:00', end: '17:00', notes: 'Full-day sprint on cosmolocal order flow' },
{ destId: dest2Id, title: 'Commons Fest Presentation', category: 'conference', date: '2026-04-17', start: '14:00', end: '16:00', notes: 'Present rFunds + rVote governance tools' },
{ destId: dest2Id, title: 'Commons Fest Presentation', category: 'conference', date: '2026-04-17', start: '14:00', end: '16:00', notes: 'Present rFlows + rVote governance tools' },
];
for (let i = 0; i < itin.length; i++) {
const iId = crypto.randomUUID();

View File

@ -90,7 +90,7 @@ export function renderLanding(): string {
<div class="rl-integration">
<div class="rl-icon-box">&#128200;</div>
<div>
<h3><a href="/rfunds" style="color:#14b8a6;text-decoration:none">rFunds</a></h3>
<h3><a href="/rflows" style="color:#14b8a6;text-decoration:none">rFlows</a></h3>
<p>Overlay budget categories on your wallet data to see where funds are allocated and how they flow.</p>
</div>
</div>

View File

@ -245,7 +245,7 @@ const DEMO_SHAPES: Record<string, unknown>[] = [
store: "StarRent.eu",
},
// ─── rFunds: Expenses ───────────────────────────────────────
// ─── rFlows: Expenses ───────────────────────────────────────
{
id: "demo-expense-shuttle",
type: "demo-expense",
@ -307,7 +307,7 @@ const DEMO_SHAPES: Record<string, unknown>[] = [
date: "2026-07-13",
},
// ─── rFunds: Budget ─────────────────────────────────────────
// ─── rFlows: Budget ─────────────────────────────────────────
{
id: "demo-budget-trip",
type: "folk-budget",

View File

@ -113,7 +113,7 @@ const TEMPLATE_SHAPES: Record<string, unknown>[] = [
store: "",
},
// ─── rFunds: Budget ─────────────────────────────────────────
// ─── rFlows: Budget ─────────────────────────────────────────
{
id: "tmpl-budget",
type: "folk-budget",
@ -129,7 +129,7 @@ const TEMPLATE_SHAPES: Record<string, unknown>[] = [
],
},
// ─── rFunds: Expense ────────────────────────────────────────
// ─── rFlows: Expense ────────────────────────────────────────
{
id: "tmpl-expense",
type: "demo-expense",

View File

@ -38,7 +38,7 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
rchoices: { badge: "rCo", color: "#f0abfc" }, // fuchsia-300
rvote: { badge: "rV", color: "#c4b5fd" }, // violet-300
// Funding & Commerce
rfunds: { badge: "rF", color: "#bef264" }, // lime-300
rflows: { badge: "rFl", color: "#bef264" }, // lime-300
rwallet: { badge: "rW", color: "#fde047" }, // yellow-300
rcart: { badge: "rCt", color: "#fdba74" }, // orange-300
rauctions: { badge: "rA", color: "#fca5a5" }, // red-300
@ -75,7 +75,7 @@ const MODULE_CATEGORIES: Record<string, string> = {
rforum: "Communicating",
rchoices: "Deciding",
rvote: "Deciding",
rfunds: "Funding & Commerce",
rflows: "Funding & Commerce",
rwallet: "Funding & Commerce",
rcart: "Funding & Commerce",
rauctions: "Funding & Commerce",

View File

@ -39,7 +39,7 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
rforum: { badge: "rFo", color: "#fcd34d" },
rchoices: { badge: "rCo", color: "#f0abfc" },
rvote: { badge: "rV", color: "#c4b5fd" },
rfunds: { badge: "rF", color: "#bef264" },
rflows: { badge: "rFl", color: "#bef264" },
rwallet: { badge: "rW", color: "#fde047" },
rcart: { badge: "rCt", color: "#fdba74" },
rauctions: { badge: "rA", color: "#fca5a5" },
@ -60,7 +60,7 @@ const MODULE_CATEGORIES: Record<string, string> = {
rcal: "Planning", rtrips: "Planning", rmaps: "Planning",
rchats: "Communicating", rinbox: "Communicating", rmail: "Communicating", rforum: "Communicating",
rchoices: "Deciding", rvote: "Deciding",
rfunds: "Funding & Commerce", rwallet: "Funding & Commerce", rcart: "Funding & Commerce", rauctions: "Funding & Commerce",
rflows: "Funding & Commerce", rwallet: "Funding & Commerce", rcart: "Funding & Commerce", rauctions: "Funding & Commerce",
rphotos: "Sharing", rnetwork: "Sharing", rsocials: "Sharing", rfiles: "Sharing", rbooks: "Sharing",
rdata: "Observing",
rwork: "Work & Productivity",

View File

@ -214,51 +214,51 @@ export default defineConfig({
resolve(__dirname, "dist/modules/rchoices/choices.css"),
);
// Build funds module components
const fundsAlias = {
"../lib/types": resolve(__dirname, "modules/rfunds/lib/types.ts"),
"../lib/simulation": resolve(__dirname, "modules/rfunds/lib/simulation.ts"),
"../lib/presets": resolve(__dirname, "modules/rfunds/lib/presets.ts"),
"../lib/map-flow": resolve(__dirname, "modules/rfunds/lib/map-flow.ts"),
// Build flows module components
const flowsAlias = {
"../lib/types": resolve(__dirname, "modules/rflows/lib/types.ts"),
"../lib/simulation": resolve(__dirname, "modules/rflows/lib/simulation.ts"),
"../lib/presets": resolve(__dirname, "modules/rflows/lib/presets.ts"),
"../lib/map-flow": resolve(__dirname, "modules/rflows/lib/map-flow.ts"),
};
await build({
configFile: false,
root: resolve(__dirname, "modules/rfunds/components"),
resolve: { alias: fundsAlias },
root: resolve(__dirname, "modules/rflows/components"),
resolve: { alias: flowsAlias },
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rfunds"),
outDir: resolve(__dirname, "dist/modules/rflows"),
lib: {
entry: resolve(__dirname, "modules/rfunds/components/folk-budget-river.ts"),
entry: resolve(__dirname, "modules/rflows/components/folk-flow-river.ts"),
formats: ["es"],
fileName: () => "folk-budget-river.js",
fileName: () => "folk-flow-river.js",
},
rollupOptions: { output: { entryFileNames: "folk-budget-river.js" } },
rollupOptions: { output: { entryFileNames: "folk-flow-river.js" } },
},
});
await build({
configFile: false,
root: resolve(__dirname, "modules/rfunds/components"),
resolve: { alias: fundsAlias },
root: resolve(__dirname, "modules/rflows/components"),
resolve: { alias: flowsAlias },
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rfunds"),
outDir: resolve(__dirname, "dist/modules/rflows"),
lib: {
entry: resolve(__dirname, "modules/rfunds/components/folk-funds-app.ts"),
entry: resolve(__dirname, "modules/rflows/components/folk-flows-app.ts"),
formats: ["es"],
fileName: () => "folk-funds-app.js",
fileName: () => "folk-flows-app.js",
},
rollupOptions: { output: { entryFileNames: "folk-funds-app.js" } },
rollupOptions: { output: { entryFileNames: "folk-flows-app.js" } },
},
});
// Copy funds CSS
mkdirSync(resolve(__dirname, "dist/modules/rfunds"), { recursive: true });
// Copy flows CSS
mkdirSync(resolve(__dirname, "dist/modules/rflows"), { recursive: true });
copyFileSync(
resolve(__dirname, "modules/rfunds/components/funds.css"),
resolve(__dirname, "dist/modules/rfunds/funds.css"),
resolve(__dirname, "modules/rflows/components/flows.css"),
resolve(__dirname, "dist/modules/rflows/flows.css"),
);
// Build files module component
@ -797,7 +797,7 @@ export default defineConfig({
});
// Build demo scripts for each module that has one
const demoModules = ["cart", "vote", "funds", "notes", "cal", "tube", "trips"];
const demoModules = ["cart", "vote", "flows", "notes", "cal", "tube", "trips"];
for (const mod of demoModules) {
const dir = `r${mod}`;
const demoEntry = resolve(__dirname, `modules/${dir}/components/${mod}-demo.ts`);

View File

@ -1803,7 +1803,7 @@
<button id="new-spider-3d" title="3D Spider Plot">📊 3D Spider</button>
<button id="new-conviction" title="Conviction Ranking">⏳ Conviction</button>
<button id="new-token" title="New Token">🪙 Token</button>
<button id="embed-funds" title="Embed rFunds">🌊 rFunds</button>
<button id="embed-flows" title="Embed rFlows">🌊 rFlows</button>
<button id="embed-wallet" title="Embed rWallet">💰 rWallet</button>
<button id="embed-vote" title="Embed rVote">🗳️ rVote</button>
</div>
@ -3733,7 +3733,7 @@
{ btnId: "embed-forum", moduleId: "rforum" },
{ btnId: "embed-inbox", moduleId: "rinbox" },
{ btnId: "embed-tube", moduleId: "rtube" },
{ btnId: "embed-funds", moduleId: "rfunds" },
{ btnId: "embed-flows", moduleId: "rflows" },
{ btnId: "embed-wallet", moduleId: "rwallet" },
{ btnId: "embed-vote", moduleId: "rvote" },
{ btnId: "embed-cart", moduleId: "rcart" },

View File

@ -359,7 +359,7 @@
Every community rSpace comes with a full suite of interoperable tools &mdash;
voting, budgets, maps, files, notes, and more &mdash; all sharing the same
<span class="hl">EncryptID session</span>. Sign in once on rSpace and you're
already authenticated on rVote, rFunds, rFiles, and every other tool your
already authenticated on rVote, rFlows, rFiles, and every other tool your
community uses. No separate accounts, no OAuth redirects, no third-party identity
providers. Your community, your identity, your data.
</p>
@ -429,7 +429,7 @@
v
┌─────────── rSpace CRDT Sync Layer ───────────┐
| |
| rVote rFunds rFiles rNotes rMaps ... |
| rVote rFlows rFiles rNotes rMaps ... |
| | | | | | |
| └───────┴───────┴───────┴───────┘ |
| shared community data graph |
@ -444,7 +444,7 @@
<div class="rl-card" style="text-align: left;">
<h3>Your data, connected across tools</h3>
<p>
A budget created in <span class="hl">rFunds</span> can reference a vote
A budget created in <span class="hl">rFlows</span> can reference a vote
from <span class="hl">rVote</span>. A map pin in <span class="hl">rMaps</span>
can link to files in <span class="hl">rFiles</span> and notes in
<span class="hl">rNotes</span>. Because all r-Ecosystem tools share the same
@ -473,7 +473,7 @@
<a href="https://rspace.online/rfiles" class="ecosystem-app">📁 rFiles</a>
<a href="https://rspace.online/rnotes" class="ecosystem-app">📝 rNotes</a>
<a href="https://rspace.online/rtrips" class="ecosystem-app">✈ rTrips</a>
<a href="https://rspace.online/rfunds" class="ecosystem-app">💸 rFunds</a>
<a href="https://rspace.online/rflows" class="ecosystem-app">💸 rFlows</a>
<a href="https://rspace.online/rnetwork" class="ecosystem-app">🕸️ rNetwork</a>
<a href="https://rspace.online/rcart" class="ecosystem-app">🛒 rCart</a>
<a href="https://rspace.online/rtube" class="ecosystem-app">🎬 rTube</a>

File diff suppressed because one or more lines are too long