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

View File

@ -75,10 +75,10 @@ services:
- "traefik.http.routers.rspace-rchoices.entrypoints=web" - "traefik.http.routers.rspace-rchoices.entrypoints=web"
- "traefik.http.routers.rspace-rchoices.priority=120" - "traefik.http.routers.rspace-rchoices.priority=120"
- "traefik.http.routers.rspace-rchoices.service=rspace-online" - "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-rflows.rule=Host(`rflows.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rflows.online`)"
- "traefik.http.routers.rspace-rfunds.entrypoints=web" - "traefik.http.routers.rspace-rflows.entrypoints=web"
- "traefik.http.routers.rspace-rfunds.priority=120" - "traefik.http.routers.rspace-rflows.priority=120"
- "traefik.http.routers.rspace-rfunds.service=rspace-online" - "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.rule=Host(`rforum.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rforum.online`)"
- "traefik.http.routers.rspace-rforum.entrypoints=web" - "traefik.http.routers.rspace-rforum.entrypoints=web"
- "traefik.http.routers.rspace-rforum.priority=120" - "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: "💬" }, rforum: { badge: "rFo", color: "#fcd34d", name: "rForum", icon: "💬" },
rinbox: { badge: "rI", color: "#a5b4fc", name: "rInbox", icon: "📧" }, rinbox: { badge: "rI", color: "#a5b4fc", name: "rInbox", icon: "📧" },
rtube: { badge: "rTu", color: "#f9a8d4", name: "rTube", 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: "💰" }, rwallet: { badge: "rW", color: "#fde047", name: "rWallet", icon: "💰" },
rvote: { badge: "rV", color: "#c4b5fd", name: "rVote", icon: "🗳️" }, rvote: { badge: "rV", color: "#c4b5fd", name: "rVote", icon: "🗳️" },
rcart: { badge: "rCt", color: "#fdba74", name: "rCart", 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", path: "/api/flows",
transform: (data) => { transform: (data) => {
const flows = Array.isArray(data) ? data : []; const flows = Array.isArray(data) ? data : [];

View File

@ -191,11 +191,11 @@ function seedDemoIfEmpty(space: string) {
sourceId: sprintsId, allDay: true, sourceId: sprintsId, allDay: true,
}, },
{ {
title: "rFunds Budget Review", title: "rFlows Budget Review",
desc: "Quarterly review of treasury flows, enoughness thresholds, and overflow routing.", desc: "Quarterly review of treasury flows, enoughness thresholds, and overflow routing.",
start: daysFromNow(6, 15, 0), end: daysFromNow(6, 17, 0), start: daysFromNow(6, 15, 0), end: daysFromNow(6, 17, 0),
sourceId: communityId, isVirtual: true, 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", title: "Cosmolocal Design Sprint",

View File

@ -41,7 +41,7 @@ export function renderLanding(): string {
<div class="rl-card rl-card--center"> <div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128176;</div> <div class="rl-icon-box">&#128176;</div>
<h3>Revenue Splits</h3> <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> </div>
</div> </div>
@ -82,7 +82,7 @@ export function renderLanding(): string {
</p> </p>
<ul class="rl-check-list"> <ul class="rl-check-list">
<li><strong>Provider matching</strong> &mdash; automatic routing by capability, location, and cost</li> <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>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> <li><strong>Volume pricing</strong> &mdash; automatic tier detection from pooled orders</li>
</ul> </ul>

View File

@ -29,7 +29,7 @@ class FolkAnalyticsView extends HTMLElement {
cookiesSet: 0, cookiesSet: 0,
scriptSize: "~2KB", scriptSize: "~2KB",
selfHosted: true, 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", dashboardUrl: "https://analytics.rspace.online",
}; };
this.render(); 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 UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || "292f6ac6-79f8-497b-ba6a-7a51e3b87b9f";
const TRACKED_APPS = [ const TRACKED_APPS = [
"rSpace", "rNotes", "rVote", "rFunds", "rCart", "rWallet", "rSpace", "rNotes", "rVote", "rFlows", "rCart", "rWallet",
"rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles", "rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles",
"rTrips", "rTube", "rWork", "rNetwork", "rData", "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, * Connects via DemoSync, extracts expenses and budget from shapes,
* renders/updates budget overview, expense list, balances, settlements, * 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 ──────────────────────────── */ /* ── Shared utility classes ──────────────────────────── */
.funds-loading { text-align: center; color: #64748b; padding: 48px 16px; font-size: 14px; } .flows-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-error { text-align: center; color: #ef4444; padding: 20px 16px; font-size: 14px; }
/* ── Landing page ────────────────────────────────────── */ /* ── 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 */ /* Features grid */
.funds-features { margin-bottom: 48px; } .flows-features { margin-bottom: 48px; }
.funds-features__grid { .flows-features__grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px; display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px;
} }
.funds-features__card { .flows-features__card {
background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 20px; background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 20px;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
.funds-features__card:hover { border-color: #475569; } .flows-features__card:hover { border-color: #475569; }
.funds-features__icon { font-size: 24px; margin-bottom: 8px; } .flows-features__icon { font-size: 24px; margin-bottom: 8px; }
.funds-features__card h3 { font-size: 14px; font-weight: 600; color: #e2e8f0; margin: 0 0 6px; } .flows-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 p { font-size: 12px; color: #94a3b8; line-height: 1.6; margin: 0; }
/* Flow list */ /* Flow list */
.funds-flows { margin-bottom: 48px; } .flows-flows { margin-bottom: 48px; }
.funds-flows__header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; } .flows-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; } .flows-flows__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0; }
.funds-flows__user { font-size: 12px; color: #64748b; } .flows-flows__user { font-size: 12px; color: #64748b; }
.funds-flows__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; } .flows-flows__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
.funds-flows__empty { .flows-flows__empty {
text-align: center; color: #64748b; padding: 32px 16px; font-size: 14px; 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; } .flows-flows__empty a { color: #6366f1; text-decoration: none; }
.funds-flows__empty a:hover { text-decoration: underline; } .flows-flows__empty a:hover { text-decoration: underline; }
.funds-flow-card { .flows-flow-card {
display: block; text-decoration: none; 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; padding: 16px; cursor: pointer; transition: border-color 0.2s, transform 0.15s;
} }
.funds-flow-card:hover { border-color: #6366f1; transform: translateY(-1px); } .flows-flow-card:hover { border-color: #6366f1; transform: translateY(-1px); }
.funds-flow-card__name { font-size: 15px; font-weight: 600; color: #e2e8f0; margin-bottom: 4px; } .flows-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; } .flows-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__meta { font-size: 12px; color: #64748b; }
/* About / how-it-works section */ /* About / how-it-works section */
.funds-about { margin-bottom: 48px; } .flows-about { margin-bottom: 48px; }
.funds-about__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0 0 20px; } .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") */ /* Steps layout (replaces the old card grid for "how it works") */
.funds-about__steps { display: flex; flex-direction: column; gap: 16px; } .flows-about__steps { display: flex; flex-direction: column; gap: 16px; }
.funds-about__step { .flows-about__step {
display: flex; gap: 16px; align-items: flex-start; 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; width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0;
background: #4f46e5; color: #fff; font-weight: 700; font-size: 14px; background: #4f46e5; color: #fff; font-weight: 700; font-size: 14px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
} }
.funds-about__step h3 { font-size: 14px; font-weight: 600; color: #e2e8f0; margin: 0 0 4px; } .flows-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 p { font-size: 13px; color: #94a3b8; line-height: 1.6; margin: 0; }
/* Legacy about grid (kept for compat) */ /* Legacy about grid (kept for compat) */
.funds-about__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; } .flows-about__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
.funds-about__card { .flows-about__card {
background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 20px; background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 20px;
} }
.funds-about__icon { font-size: 28px; margin-bottom: 8px; } .flows-about__icon { font-size: 28px; margin-bottom: 8px; }
.funds-about__card h3 { font-size: 15px; font-weight: 600; color: #e2e8f0; margin: 0 0 8px; } .flows-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__card p { font-size: 13px; color: #94a3b8; line-height: 1.6; margin: 0; }
/* ── Detail view ─────────────────────────────────────── */ /* ── 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 ────────────────────────────────────────────── */ /* ── Tabs ────────────────────────────────────────────── */
.funds-tabs { .flows-tabs {
display: flex; gap: 4px; border-bottom: 1px solid #1e293b; margin-bottom: 20px; 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; padding: 8px 18px; border: none; border-bottom: 2px solid transparent;
background: transparent; color: #64748b; font-size: 13px; font-weight: 500; background: transparent; color: #64748b; font-size: 13px; font-weight: 500;
cursor: pointer; transition: color 0.2s, border-color 0.2s; cursor: pointer; transition: color 0.2s, border-color 0.2s;
} }
.funds-tab:hover { color: #e2e8f0; } .flows-tab:hover { color: #e2e8f0; }
.funds-tab--active { color: #e2e8f0; border-bottom-color: #6366f1; } .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 ───────────────────────────── */ /* ── Table tab — card grid ───────────────────────────── */
.funds-table { } .flows-table { }
.funds-section { margin-bottom: 28px; } .flows-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; } .flows-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-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.funds-card { .flows-card {
background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 16px; background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 16px;
} }
.funds-card__header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } .flows-card__header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.funds-card__icon { font-size: 18px; } .flows-card__icon { font-size: 18px; }
.funds-card__label { font-size: 14px; font-weight: 600; color: #e2e8f0; flex: 1; } .flows-card__label { font-size: 14px; font-weight: 600; color: #e2e8f0; flex: 1; }
.funds-card__type { font-size: 11px; color: #64748b; text-transform: uppercase; } .flows-card__type { font-size: 11px; color: #64748b; text-transform: uppercase; }
.funds-card__status { font-size: 11px; font-weight: 600; text-transform: capitalize; } .flows-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__desc { font-size: 12px; color: #94a3b8; margin-bottom: 10px; line-height: 1.5; }
.funds-card__stat { margin-bottom: 10px; } .flows-card__stat { margin-bottom: 10px; }
.funds-card__stat-value { font-size: 18px; font-weight: 700; color: #e2e8f0; } .flows-card__stat-value { font-size: 18px; font-weight: 700; color: #e2e8f0; }
.funds-card__stat-label { font-size: 12px; color: #64748b; margin-left: 4px; } .flows-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__stats { display: flex; justify-content: space-between; margin-bottom: 8px; }
/* Progress bar */ /* Progress bar */
.funds-card__bar-container { .flows-card__bar-container {
position: relative; height: 6px; background: #334155; border-radius: 3px; position: relative; height: 6px; background: #334155; border-radius: 3px;
margin-bottom: 10px; overflow: visible; margin-bottom: 10px; overflow: visible;
} }
.funds-card__bar { .flows-card__bar {
height: 100%; border-radius: 3px; background: #0ea5e9; height: 100%; border-radius: 3px; background: #0ea5e9;
transition: width 0.3s ease; transition: width 0.3s ease;
} }
.funds-card__bar--outcome { opacity: 0.8; } .flows-card__bar--outcome { opacity: 0.8; }
.funds-card__bar-threshold { .flows-card__bar-threshold {
position: absolute; top: -3px; width: 2px; height: 12px; position: absolute; top: -3px; width: 2px; height: 12px;
background: #fbbf24; border-radius: 1px; background: #fbbf24; border-radius: 1px;
} }
.funds-card__thresholds { .flows-card__thresholds {
display: flex; gap: 12px; font-size: 11px; color: #64748b; margin-bottom: 8px; display: flex; gap: 12px; font-size: 11px; color: #64748b; margin-bottom: 8px;
} }
/* Allocation lists */ /* Allocation lists */
.funds-card__allocs { margin-top: 8px; padding-top: 8px; border-top: 1px solid #334155; } .flows-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; } .flows-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; } .flows-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__alloc-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
/* Status colors */ /* Status colors */
.funds-status--abundant { color: #fbbf24; } .flows-status--abundant { color: #fbbf24; }
.funds-status--sufficient { color: #10b981; } .flows-status--sufficient { color: #10b981; }
.funds-status--seeking { color: #0ea5e9; } .flows-status--seeking { color: #0ea5e9; }
.funds-status--critical { color: #ef4444; } .flows-status--critical { color: #ef4444; }
/* ── Interactive canvas (Diagram tab) ───────────────── */ /* ── Interactive canvas (Diagram tab) ───────────────── */
.funds-canvas-container { .flows-canvas-container {
position: relative; height: 70vh; min-height: 400px; position: relative; height: 70vh; min-height: 400px;
background: #0f172a; border-radius: 12px; border: 1px solid #334155; 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; width: 100%; height: 100%; display: block;
cursor: grab; cursor: grab;
} }
.funds-canvas-svg.panning { cursor: grabbing; } .flows-canvas-svg.panning { cursor: grabbing; }
.funds-canvas-svg.dragging { cursor: move; } .flows-canvas-svg.dragging { cursor: move; }
/* Toolbar — top-right overlay */ /* Toolbar — top-right overlay */
.funds-canvas-toolbar { .flows-canvas-toolbar {
position: absolute; top: 10px; right: 10px; z-index: 10; position: absolute; top: 10px; right: 10px; z-index: 10;
display: flex; gap: 4px; flex-wrap: wrap; align-items: center; 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; padding: 5px 10px; border: 1px solid #475569; border-radius: 6px;
background: #1e293b; color: #e2e8f0; font-size: 11px; font-weight: 500; background: #1e293b; color: #e2e8f0; font-size: 11px; font-weight: 500;
cursor: pointer; white-space: nowrap; transition: background 0.15s, border-color 0.15s; cursor: pointer; white-space: nowrap; transition: background 0.15s, border-color 0.15s;
} }
.funds-canvas-btn:hover { background: #334155; border-color: #64748b; } .flows-canvas-btn:hover { background: #334155; border-color: #64748b; }
.funds-canvas-btn--source { border-color: #10b981; color: #6ee7b7; } .flows-canvas-btn--source { border-color: #10b981; color: #6ee7b7; }
.funds-canvas-btn--source:hover { background: #064e3b; } .flows-canvas-btn--source:hover { background: #064e3b; }
.funds-canvas-btn--funnel { border-color: #3b82f6; color: #93c5fd; } .flows-canvas-btn--funnel { border-color: #3b82f6; color: #93c5fd; }
.funds-canvas-btn--funnel:hover { background: #1e3a5f; } .flows-canvas-btn--funnel:hover { background: #1e3a5f; }
.funds-canvas-btn--outcome { border-color: #ec4899; color: #f9a8d4; } .flows-canvas-btn--outcome { border-color: #ec4899; color: #f9a8d4; }
.funds-canvas-btn--outcome:hover { background: #4a1942; } .flows-canvas-btn--outcome:hover { background: #4a1942; }
.funds-canvas-btn--active { background: #4f46e5; border-color: #6366f1; color: #fff; } .flows-canvas-btn--active { background: #4f46e5; border-color: #6366f1; color: #fff; }
.funds-canvas-sep { .flows-canvas-sep {
width: 1px; height: 20px; background: #334155; margin: 0 4px; 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)); } .node-glow { filter: drop-shadow(0 0 6px rgba(251,191,36,0.5)); }
/* Editor panel — right side slide-in */ /* 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; position: absolute; top: 0; right: 0; bottom: 0; width: 320px; z-index: 20;
background: #1e293b; border-left: 1px solid #334155; background: #1e293b; border-left: 1px solid #334155;
transform: translateX(100%); transition: transform 0.25s ease; transform: translateX(100%); transition: transform 0.25s ease;
overflow-y: auto; padding: 16px; overflow-y: auto; padding: 16px;
display: flex; flex-direction: column; gap: 12px; display: flex; flex-direction: column; gap: 12px;
} }
.funds-editor-panel.open { transform: translateX(0); } .flows-editor-panel.open { transform: translateX(0); }
.editor-header { .editor-header {
display: flex; align-items: center; justify-content: space-between; 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; } .edge-pct { color: #e2e8f0; font-weight: 600; min-width: 30px; text-align: center; }
/* Legend — bottom-left */ /* Legend — bottom-left */
.funds-canvas-legend { .flows-canvas-legend {
position: absolute; bottom: 10px; left: 10px; z-index: 10; position: absolute; bottom: 10px; left: 10px; z-index: 10;
display: flex; flex-wrap: wrap; gap: 12px; display: flex; flex-wrap: wrap; gap: 12px;
font-size: 11px; color: #94a3b8; background: rgba(15,23,42,0.85); font-size: 11px; color: #94a3b8; background: rgba(15,23,42,0.85);
padding: 6px 10px; border-radius: 8px; padding: 6px 10px; border-radius: 8px;
} }
.funds-canvas-legend-item { display: flex; align-items: center; gap: 4px; } .flows-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-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
/* Zoom controls — bottom-right */ /* Zoom controls — bottom-right */
.funds-canvas-zoom { .flows-canvas-zoom {
position: absolute; bottom: 10px; right: 10px; z-index: 10; position: absolute; bottom: 10px; right: 10px; z-index: 10;
display: flex; gap: 4px; display: flex; gap: 4px;
} }
/* Sufficiency badge — top-left */ /* Sufficiency badge — top-left */
.funds-canvas-badge { .flows-canvas-badge {
position: absolute; top: 10px; left: 10px; z-index: 10; 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; display: flex; align-items: center; gap: 8px;
} }
.funds-canvas-badge__score { font-size: 20px; font-weight: 700; } .flows-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__label { font-size: 10px; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; }
/* Legacy diagram (kept for compat) */ /* Legacy diagram (kept for compat) */
.funds-diagram { overflow-x: auto; } .flows-diagram { overflow-x: auto; }
.funds-diagram svg { display: block; margin: 0 auto; } .flows-diagram svg { display: block; margin: 0 auto; }
.funds-diagram__legend { .flows-diagram__legend {
display: flex; flex-wrap: wrap; gap: 16px; justify-content: center; display: flex; flex-wrap: wrap; gap: 16px; justify-content: center;
margin-top: 12px; font-size: 12px; color: #94a3b8; margin-top: 12px; font-size: 12px; color: #94a3b8;
} }
.funds-diagram__legend-item { display: flex; align-items: center; gap: 5px; } .flows-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__dot { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
/* ── River tab ───────────────────────────────────────── */ /* ── River tab ───────────────────────────────────────── */
.funds-river-container { min-height: 500px; } .flows-river-container { min-height: 500px; }
/* ── Transactions tab ────────────────────────────────── */ /* ── Transactions tab ────────────────────────────────── */
.funds-tx-list { display: flex; flex-direction: column; gap: 4px; } .flows-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-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; display: flex; align-items: center; gap: 12px; padding: 12px 16px;
background: #1e293b; border: 1px solid #334155; border-radius: 8px; background: #1e293b; border: 1px solid #334155; border-radius: 8px;
} }
.funds-tx__icon { font-size: 16px; flex-shrink: 0; } .flows-tx__icon { font-size: 16px; flex-shrink: 0; }
.funds-tx__body { flex: 1; min-width: 0; } .flows-tx__body { flex: 1; min-width: 0; }
.funds-tx__desc { font-size: 13px; color: #e2e8f0; font-weight: 500; } .flows-tx__desc { font-size: 13px; color: #e2e8f0; font-weight: 500; }
.funds-tx__meta { font-size: 11px; color: #64748b; margin-top: 2px; } .flows-tx__meta { font-size: 11px; color: #64748b; margin-top: 2px; }
.funds-tx__amount { font-size: 14px; font-weight: 600; white-space: nowrap; } .flows-tx__amount { font-size: 14px; font-weight: 600; white-space: nowrap; }
.funds-tx__amount--positive { color: #10b981; } .flows-tx__amount--positive { color: #10b981; }
.funds-tx__amount--negative { color: #ef4444; } .flows-tx__amount--negative { color: #ef4444; }
.funds-tx__time { font-size: 11px; color: #64748b; white-space: nowrap; } .flows-tx__time { font-size: 11px; color: #64748b; white-space: nowrap; }
/* ── Port & wiring ──────────────────────────────────── */ /* ── Port & wiring ──────────────────────────────────── */
.port-group { pointer-events: all; } .port-group { pointer-events: all; }
@ -312,7 +325,7 @@
stroke-linecap: round; animation: wire-dash 0.6s linear infinite; 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 { @keyframes port-glow {
0%, 100% { filter: drop-shadow(0 0 4px currentColor); } 0%, 100% { filter: drop-shadow(0 0 4px currentColor); }
@ -342,40 +355,40 @@
.satisfaction-bar-fill { transition: width 0.3s ease; } .satisfaction-bar-fill { transition: width 0.3s ease; }
/* ── Node detail modals ──────────────────────────────── */ /* ── Node detail modals ──────────────────────────────── */
.funds-modal-backdrop { .flows-modal-backdrop {
position: fixed; inset: 0; z-index: 50; position: fixed; inset: 0; z-index: 50;
background: rgba(0,0,0,0.6); display: flex; background: rgba(0,0,0,0.6); display: flex;
align-items: center; justify-content: center; align-items: center; justify-content: center;
animation: modalFadeIn 0.15s ease-out; animation: modalFadeIn 0.15s ease-out;
} }
@keyframes modalFadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes modalFadeIn { from { opacity: 0; } to { opacity: 1; } }
.funds-modal { .flows-modal {
background: #1e293b; border-radius: 16px; padding: 24px; background: #1e293b; border-radius: 16px; padding: 24px;
width: 440px; max-height: 85vh; overflow-y: auto; width: 440px; max-height: 85vh; overflow-y: auto;
border: 1px solid #334155; box-shadow: 0 20px 60px rgba(0,0,0,0.5); border: 1px solid #334155; box-shadow: 0 20px 60px rgba(0,0,0,0.5);
animation: modalSlideIn 0.2s ease-out; animation: modalSlideIn 0.2s ease-out;
} }
@keyframes modalSlideIn { from { transform: translateY(12px); opacity: 0; } to { transform: none; opacity: 1; } } @keyframes modalSlideIn { from { transform: translateY(12px); opacity: 0; } to { transform: none; opacity: 1; } }
.funds-modal::-webkit-scrollbar { width: 6px; } .flows-modal::-webkit-scrollbar { width: 6px; }
.funds-modal::-webkit-scrollbar-track { background: transparent; } .flows-modal::-webkit-scrollbar-track { background: transparent; }
.funds-modal::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; } .flows-modal::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
.funds-modal__header { .flows-modal__header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; 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; background: none; border: none; color: #94a3b8; font-size: 24px; cursor: pointer;
padding: 2px 8px; border-radius: 4px; transition: color 0.15s; padding: 2px 8px; border-radius: 4px; transition: color 0.15s;
} }
.funds-modal__close:hover { color: #e2e8f0; } .flows-modal__close:hover { color: #e2e8f0; }
.funds-modal__progress-bar { .flows-modal__progress-bar {
height: 8px; background: #334155; border-radius: 4px; overflow: hidden; margin-top: 8px; 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 accordion */
.phase-tier-bar { display: flex; gap: 1px; height: 8px; border-radius: 4px; overflow: hidden; margin-bottom: 16px; } .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-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-card--locked { opacity: 0.5; }
.phase-header { .phase-header {
padding: 10px 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; 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-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; }
.source-type-btn { .source-type-btn {
display: flex; flex-direction: column; align-items: center; gap: 6px; 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; background: #0f172a; color: #94a3b8; cursor: pointer; transition: all 0.15s;
font-size: 12px; font-weight: 500; font-size: 12px; font-weight: 500;
} }
@ -407,14 +420,14 @@
.source-type-btn--active { border-color: #10b981; background: #064e3b; color: #6ee7b7; } .source-type-btn--active { border-color: #10b981; background: #064e3b; color: #6ee7b7; }
/* Node hover tooltip */ /* Node hover tooltip */
.funds-node-tooltip { .flows-node-tooltip {
position: absolute; z-index: 30; pointer-events: none; position: absolute; z-index: 30; pointer-events: none;
background: rgba(15,23,42,0.95); border: 1px solid #475569; border-radius: 8px; background: rgba(15,23,42,0.95); border: 1px solid #475569; border-radius: 8px;
padding: 8px 12px; font-size: 12px; color: #e2e8f0; padding: 8px 12px; font-size: 12px; color: #e2e8f0;
box-shadow: 0 4px 12px rgba(0,0,0,0.4); white-space: nowrap; box-shadow: 0 4px 12px rgba(0,0,0,0.4); white-space: nowrap;
} }
.funds-node-tooltip__label { font-weight: 600; margin-bottom: 2px; } .flows-node-tooltip__label { font-weight: 600; margin-bottom: 2px; }
.funds-node-tooltip__stat { color: #94a3b8; font-size: 11px; } .flows-node-tooltip__stat { color: #94a3b8; font-size: 11px; }
/* Sufficiency glow on funnel status text */ /* Sufficiency glow on funnel status text */
@keyframes sufficiencyPulse { @keyframes sufficiencyPulse {
@ -425,14 +438,17 @@
/* ── Mobile responsive ──────────────────────────────── */ /* ── Mobile responsive ──────────────────────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {
.funds-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; } .flows-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.funds-flows__grid { grid-template-columns: 1fr; } .flows-flows__grid { grid-template-columns: 1fr; }
.funds-features__grid { grid-template-columns: 1fr; } .flows-features__grid { grid-template-columns: 1fr; }
.funds-cards { grid-template-columns: 1fr; } .flows-cards { grid-template-columns: 1fr; }
.funds-tabs { flex-wrap: wrap; } .flows-tabs { flex-wrap: wrap; }
.funds-canvas-container { height: 50vh; min-height: 300px; } .flows-tab { min-height: 44px; min-width: 44px; display: flex; align-items: center; justify-content: center; }
.funds-canvas-toolbar { flex-wrap: wrap; gap: 3px; top: 6px; right: 6px; } .flows-canvas-container { height: 50vh; min-height: 300px; }
.funds-canvas-btn { padding: 4px 7px; font-size: 10px; } .flows-canvas-toolbar { flex-wrap: wrap; gap: 3px; top: 6px; right: 6px; }
.funds-editor-panel { width: 100%; } .flows-canvas-btn { padding: 6px 10px; font-size: 11px; min-height: 44px; min-width: 44px; }
.funds-canvas-legend { font-size: 10px; gap: 8px; } .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. * 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"; import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "../lib/types";
@ -382,7 +382,7 @@ function esc(s: string): string {
// ─── Web Component ────────────────────────────────────── // ─── Web Component ──────────────────────────────────────
class FolkBudgetRiver extends HTMLElement { class FolkFlowRiver extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private nodes: FlowNode[] = []; private nodes: FlowNode[] = [];
private simulating = false; 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: * Views:
* "landing" TBFF info hero + flow list cards * "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 getAccessToken(): string | null { return getSession()?.accessToken ?? null; }
function getUsername(): string | null { return getSession()?.claims?.username ?? null; } function getUsername(): string | null { return getSession()?.claims?.username ?? null; }
class FolkFundsApp extends HTMLElement { class FolkFlowsApp extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private space = ""; private space = "";
private view: View = "landing"; private view: View = "landing";
@ -101,6 +101,11 @@ class FolkFundsApp extends HTMLElement {
private wiringPointerX = 0; private wiringPointerX = 0;
private wiringPointerY = 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 // Bound handlers for cleanup
private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null; private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null;
private _boundPointerMove: ((e: PointerEvent) => void) | null = null; private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
@ -132,8 +137,8 @@ class FolkFundsApp extends HTMLElement {
private getApiBase(): string { private getApiBase(): string {
const path = window.location.pathname; const path = window.location.pathname;
// Subdomain: /rfunds/... or Direct: /{space}/rfunds/... // Subdomain: /rflows/... or Direct: /{space}/rflows/...
const match = path.match(/^(\/[^/]+)?\/rfunds/); const match = path.match(/^(\/[^/]+)?\/rflows/);
return match ? `${match[0]}` : ""; return match ? `${match[0]}` : "";
} }
@ -195,9 +200,9 @@ class FolkFundsApp extends HTMLElement {
} }
private getCssPath(): string { private getCssPath(): string {
// In rSpace: /modules/rfunds/funds.css | Standalone: /modules/rfunds/funds.css // In rSpace: /modules/rflows/flows.css | Standalone: /modules/rflows/flows.css
// The shell always serves from /modules/rfunds/ in both modes // The shell always serves from /modules/rflows/ in both modes
return "/modules/rfunds/funds.css"; return "/modules/rflows/flows.css";
} }
private render() { private render() {
@ -207,8 +212,8 @@ class FolkFundsApp extends HTMLElement {
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
</style> </style>
<link rel="stylesheet" href="${this.getCssPath()}"> <link rel="stylesheet" href="${this.getCssPath()}">
${this.error ? `<div class="funds-error">${this.esc(this.error)}</div>` : ""} ${this.error ? `<div class="flows-error">${this.esc(this.error)}</div>` : ""}
${this.loading && this.view === "landing" ? '<div class="funds-loading">Loading...</div>' : ""} ${this.loading && this.view === "landing" ? '<div class="flows-loading">Loading...</div>' : ""}
${this.renderView()} ${this.renderView()}
`; `;
this.attachListeners(); this.attachListeners();
@ -222,12 +227,12 @@ class FolkFundsApp extends HTMLElement {
// ─── Landing page ────────────────────────────────────── // ─── Landing page ──────────────────────────────────────
private renderLanding(): string { 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 authed = isAuthenticated();
const username = getUsername(); const username = getUsername();
return ` return `
<div class="funds-landing"> <div class="flows-landing">
<div class="rapp-nav"> <div class="rapp-nav">
<span class="rapp-nav__title">Flows</span> <span class="rapp-nav__title">Flows</span>
<div class="rapp-nav__actions"> <div class="rapp-nav__actions">
@ -239,53 +244,53 @@ class FolkFundsApp extends HTMLElement {
</div> </div>
</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. Design transparent resource flows with sufficiency-based cascading.
Funnels fill to their threshold, then overflow routes surplus to the next layer &mdash; 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. ensuring every level has <em style="color:#fbbf24;font-style:normal;font-weight:600">enough</em> before abundance cascades forward.
</div> </div>
<div class="funds-features"> <div class="flows-features">
<div class="funds-features__grid"> <div class="flows-features__grid">
<div class="funds-features__card"> <div class="flows-features__card">
<div class="funds-features__icon">&#x1F4B0;</div> <div class="flows-features__icon">&#x1F4B0;</div>
<h3>Sources</h3> <h3>Sources</h3>
<p>Revenue streams split across funnels by configurable allocation percentages.</p> <p>Revenue streams split across funnels by configurable allocation percentages.</p>
</div> </div>
<div class="funds-features__card"> <div class="flows-features__card">
<div class="funds-features__icon">&#x1F3DB;</div> <div class="flows-features__icon">&#x1F3DB;</div>
<h3>Funnels</h3> <h3>Funnels</h3>
<p>Budget buckets with min/max thresholds and sufficiency-based overflow cascading.</p> <p>Budget buckets with min/max thresholds and sufficiency-based overflow cascading.</p>
</div> </div>
<div class="funds-features__card"> <div class="flows-features__card">
<div class="funds-features__icon">&#x1F3AF;</div> <div class="flows-features__icon">&#x1F3AF;</div>
<h3>Outcomes</h3> <h3>Outcomes</h3>
<p>Funding targets that receive spending allocations. Track progress toward each goal.</p> <p>Funding targets that receive spending allocations. Track progress toward each goal.</p>
</div> </div>
<div class="funds-features__card"> <div class="flows-features__card">
<div class="funds-features__icon">&#x1F30A;</div> <div class="flows-features__icon">&#x1F30A;</div>
<h3>River View</h3> <h3>River View</h3>
<p>Animated sankey diagram showing live fund flows through your entire system.</p> <p>Animated sankey diagram showing live fund flows through your entire system.</p>
</div> </div>
<div class="funds-features__card"> <div class="flows-features__card">
<div class="funds-features__icon">&#x2728;</div> <div class="flows-features__icon">&#x2728;</div>
<h3>Enoughness</h3> <h3>Enoughness</h3>
<p>System-wide sufficiency scoring. Golden glow when funnels reach their threshold.</p> <p>System-wide sufficiency scoring. Golden glow when funnels reach their threshold.</p>
</div> </div>
</div> </div>
</div> </div>
<div class="funds-flows"> <div class="flows-flows">
<div class="funds-flows__header"> <div class="flows-flows__header">
<h2 class="funds-flows__heading">${authed ? `Flows in ${this.esc(this.space)}` : "Your Flows"}</h2> <h2 class="flows-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>` : ""} ${authed ? `<span class="flows-flows__user">Signed in as ${this.esc(username || "")}</span>` : ""}
</div> </div>
${this.flows.length > 0 ? ` ${this.flows.length > 0 ? `
<div class="funds-flows__grid"> <div class="flows-flows__grid">
${this.flows.map((f) => this.renderFlowCard(f)).join("")} ${this.flows.map((f) => this.renderFlowCard(f)).join("")}
</div> </div>
` : ` ` : `
<div class="funds-flows__empty"> <div class="flows-flows__empty">
${authed ${authed
? `<p>No flows in this space yet.</p> ? `<p>No flows in this space yet.</p>
<p><a href="${this.esc(demoUrl)}">Explore the demo</a> or create your first flow.</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>
<div class="funds-about"> <div class="flows-about">
<h2 class="funds-about__heading">How TBFF Works</h2> <h2 class="flows-about__heading">How TBFF Works</h2>
<div class="funds-about__steps"> <div class="flows-about__steps">
<div class="funds-about__step"> <div class="flows-about__step">
<div class="funds-about__step-num">1</div> <div class="flows-about__step-num">1</div>
<div> <div>
<h3>Define Sources</h3> <h3>Define Sources</h3>
<p>Add revenue streams &mdash; grants, donations, sales, or any recurring income &mdash; with allocation splits.</p> <p>Add revenue streams &mdash; grants, donations, sales, or any recurring income &mdash; with allocation splits.</p>
</div> </div>
</div> </div>
<div class="funds-about__step"> <div class="flows-about__step">
<div class="funds-about__step-num">2</div> <div class="flows-about__step-num">2</div>
<div> <div>
<h3>Configure Funnels</h3> <h3>Configure Funnels</h3>
<p>Set minimum, sufficient, and maximum thresholds. Overflow rules determine where surplus flows.</p> <p>Set minimum, sufficient, and maximum thresholds. Overflow rules determine where surplus flows.</p>
</div> </div>
</div> </div>
<div class="funds-about__step"> <div class="flows-about__step">
<div class="funds-about__step-num">3</div> <div class="flows-about__step-num">3</div>
<div> <div>
<h3>Track Outcomes</h3> <h3>Track Outcomes</h3>
<p>Funding targets receive allocations as funnels reach sufficiency. Watch the river flow in real time.</p> <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 { private renderFlowCard(f: FlowSummary): string {
const detailUrl = this.getApiBase() const detailUrl = this.getApiBase()
? `${this.getApiBase()}/flow/${encodeURIComponent(f.id)}` ? `${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()}` : ""; const value = f.totalValue != null ? `$${Math.floor(f.totalValue).toLocaleString()}` : "";
return ` return `
<a href="${this.esc(detailUrl)}" class="funds-flow-card" data-flow="${this.esc(f.id)}"> <a href="${this.esc(detailUrl)}" class="flows-flow-card" data-flow="${this.esc(f.id)}">
<div class="funds-flow-card__name">${this.esc(f.name || f.label || f.id)}</div> <div class="flows-flow-card__name">${this.esc(f.name || f.label || f.id)}</div>
${value ? `<div class="funds-flow-card__value">${value}</div>` : ""} ${value ? `<div class="flows-flow-card__value">${value}</div>` : ""}
<div class="funds-flow-card__meta"> <div class="flows-flow-card__meta">
${f.funnelCount != null ? `${f.funnelCount} funnels` : ""} ${f.funnelCount != null ? `${f.funnelCount} funnels` : ""}
${f.outcomeCount != null ? ` &middot; ${f.outcomeCount} outcomes` : ""} ${f.outcomeCount != null ? ` &middot; ${f.outcomeCount} outcomes` : ""}
${f.status ? ` &middot; ${f.status}` : ""} ${f.status ? ` &middot; ${f.status}` : ""}
@ -347,25 +352,25 @@ class FolkFundsApp extends HTMLElement {
private renderDetail(): string { private renderDetail(): string {
const backUrl = this.getApiBase() const backUrl = this.getApiBase()
? `${this.getApiBase()}/` ? `${this.getApiBase()}/`
: "/rfunds/"; : "/rflows/";
return ` return `
<div class="funds-detail"> <div class="flows-detail">
<div class="rapp-nav"> <div class="rapp-nav">
<a href="${this.esc(backUrl)}" class="rapp-nav__back">&larr; Flows</a> <a href="${this.esc(backUrl)}" class="rapp-nav__back">&larr; Flows</a>
<span class="rapp-nav__title">${this.esc(this.flowName || "Flow Detail")}</span> <span class="rapp-nav__title">${this.esc(this.flowName || "Flow Detail")}</span>
${this.isDemo ? '<span class="rapp-nav__badge">Demo</span>' : ""} ${this.isDemo ? '<span class="rapp-nav__badge">Demo</span>' : ""}
</div> </div>
<div class="funds-tabs"> <div class="flows-tabs">
<button class="funds-tab ${this.tab === "diagram" ? "funds-tab--active" : ""}" data-tab="diagram">Diagram</button> <button class="flows-tab ${this.tab === "diagram" ? "flows-tab--active" : ""}" data-tab="diagram">Diagram</button>
<button class="funds-tab ${this.tab === "river" ? "funds-tab--active" : ""}" data-tab="river">River</button> <button class="flows-tab ${this.tab === "river" ? "flows-tab--active" : ""}" data-tab="river">River</button>
<button class="funds-tab ${this.tab === "table" ? "funds-tab--active" : ""}" data-tab="table">Table</button> <button class="flows-tab ${this.tab === "table" ? "flows-tab--active" : ""}" data-tab="table">Table</button>
<button class="funds-tab ${this.tab === "transactions" ? "funds-tab--active" : ""}" data-tab="transactions">Transactions</button> <button class="flows-tab ${this.tab === "transactions" ? "flows-tab--active" : ""}" data-tab="transactions">Transactions</button>
</div> </div>
<div class="funds-tab-content"> <div class="flows-tab-content">
${this.loading ? '<div class="funds-loading">Loading...</div>' : this.renderTab()} ${this.loading ? '<div class="flows-loading">Loading...</div>' : this.renderTab()}
</div> </div>
</div>`; </div>`;
} }
@ -385,26 +390,26 @@ class FolkFundsApp extends HTMLElement {
const sources = this.nodes.filter((n) => n.type === "source"); const sources = this.nodes.filter((n) => n.type === "source");
return ` return `
<div class="funds-table"> <div class="flows-table">
${sources.length > 0 ? ` ${sources.length > 0 ? `
<div class="funds-section"> <div class="flows-section">
<h3 class="funds-section__title">Sources</h3> <h3 class="flows-section__title">Sources</h3>
<div class="funds-cards"> <div class="flows-cards">
${sources.map((n) => this.renderSourceCard(n.data as SourceNodeData, n.id)).join("")} ${sources.map((n) => this.renderSourceCard(n.data as SourceNodeData, n.id)).join("")}
</div> </div>
</div> </div>
` : ""} ` : ""}
<div class="funds-section"> <div class="flows-section">
<h3 class="funds-section__title">Funnels</h3> <h3 class="flows-section__title">Funnels</h3>
<div class="funds-cards"> <div class="flows-cards">
${funnels.map((n) => this.renderFunnelCard(n.data as FunnelNodeData, n.id)).join("")} ${funnels.map((n) => this.renderFunnelCard(n.data as FunnelNodeData, n.id)).join("")}
</div> </div>
</div> </div>
<div class="funds-section"> <div class="flows-section">
<h3 class="funds-section__title">Outcomes</h3> <h3 class="flows-section__title">Outcomes</h3>
<div class="funds-cards"> <div class="flows-cards">
${outcomes.map((n) => this.renderOutcomeCard(n.data as OutcomeNodeData, n.id)).join("")} ${outcomes.map((n) => this.renderOutcomeCard(n.data as OutcomeNodeData, n.id)).join("")}
</div> </div>
</div> </div>
@ -414,21 +419,21 @@ class FolkFundsApp extends HTMLElement {
private renderSourceCard(data: SourceNodeData, id: string): string { private renderSourceCard(data: SourceNodeData, id: string): string {
const allocations = data.targetAllocations || []; const allocations = data.targetAllocations || [];
return ` return `
<div class="funds-card"> <div class="flows-card">
<div class="funds-card__header"> <div class="flows-card__header">
<span class="funds-card__icon">&#x1F4B0;</span> <span class="flows-card__icon">&#x1F4B0;</span>
<span class="funds-card__label">${this.esc(data.label)}</span> <span class="flows-card__label">${this.esc(data.label)}</span>
<span class="funds-card__type">${data.sourceType}</span> <span class="flows-card__type">${data.sourceType}</span>
</div> </div>
<div class="funds-card__stat"> <div class="flows-card__stat">
<span class="funds-card__stat-value">$${data.flowRate.toLocaleString()}</span> <span class="flows-card__stat-value">$${data.flowRate.toLocaleString()}</span>
<span class="funds-card__stat-label">/month</span> <span class="flows-card__stat-label">/month</span>
</div> </div>
${allocations.length > 0 ? ` ${allocations.length > 0 ? `
<div class="funds-card__allocs"> <div class="flows-card__allocs">
${allocations.map((a) => ` ${allocations.map((a) => `
<div class="funds-card__alloc"> <div class="flows-card__alloc">
<span class="funds-card__alloc-dot" style="background:${a.color}"></span> <span class="flows-card__alloc-dot" style="background:${a.color}"></span>
${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))} ${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
</div> </div>
`).join("")} `).join("")}
@ -443,10 +448,10 @@ class FolkFundsApp extends HTMLElement {
const fillPct = Math.min(100, (data.currentValue / (data.maxCapacity || 1)) * 100); const fillPct = Math.min(100, (data.currentValue / (data.maxCapacity || 1)) * 100);
const suffPct = Math.min(100, (data.currentValue / (threshold || 1)) * 100); const suffPct = Math.min(100, (data.currentValue / (threshold || 1)) * 100);
const statusClass = sufficiency === "abundant" ? "funds-status--abundant" const statusClass = sufficiency === "abundant" ? "flows-status--abundant"
: sufficiency === "sufficient" ? "funds-status--sufficient" : sufficiency === "sufficient" ? "flows-status--sufficient"
: data.currentValue < data.minThreshold ? "funds-status--critical" : data.currentValue < data.minThreshold ? "flows-status--critical"
: "funds-status--seeking"; : "flows-status--seeking";
const statusLabel = sufficiency === "abundant" ? "Abundant" const statusLabel = sufficiency === "abundant" ? "Abundant"
: sufficiency === "sufficient" ? "Sufficient" : sufficiency === "sufficient" ? "Sufficient"
@ -454,48 +459,48 @@ class FolkFundsApp extends HTMLElement {
: "Seeking"; : "Seeking";
return ` return `
<div class="funds-card"> <div class="flows-card">
<div class="funds-card__header"> <div class="flows-card__header">
<span class="funds-card__icon">&#x1F3DB;</span> <span class="flows-card__icon">&#x1F3DB;</span>
<span class="funds-card__label">${this.esc(data.label)}</span> <span class="flows-card__label">${this.esc(data.label)}</span>
<span class="funds-card__status ${statusClass}">${statusLabel}</span> <span class="flows-card__status ${statusClass}">${statusLabel}</span>
</div> </div>
<div class="funds-card__bar-container"> <div class="flows-card__bar-container">
<div class="funds-card__bar" style="width:${fillPct}%"></div> <div class="flows-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-threshold" style="left:${Math.min(100, (threshold / (data.maxCapacity || 1)) * 100)}%"></div>
</div> </div>
<div class="funds-card__stats"> <div class="flows-card__stats">
<div> <div>
<span class="funds-card__stat-value">$${Math.floor(data.currentValue).toLocaleString()}</span> <span class="flows-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-label">/ $${Math.floor(threshold).toLocaleString()}</span>
</div> </div>
<div> <div>
<span class="funds-card__stat-value">${Math.round(suffPct)}%</span> <span class="flows-card__stat-value">${Math.round(suffPct)}%</span>
<span class="funds-card__stat-label">sufficiency</span> <span class="flows-card__stat-label">sufficiency</span>
</div> </div>
</div> </div>
<div class="funds-card__thresholds"> <div class="flows-card__thresholds">
<span>Min: $${Math.floor(data.minThreshold).toLocaleString()}</span> <span>Min: $${Math.floor(data.minThreshold).toLocaleString()}</span>
<span>Max: $${Math.floor(data.maxThreshold).toLocaleString()}</span> <span>Max: $${Math.floor(data.maxThreshold).toLocaleString()}</span>
<span>Cap: $${Math.floor(data.maxCapacity).toLocaleString()}</span> <span>Cap: $${Math.floor(data.maxCapacity).toLocaleString()}</span>
</div> </div>
${data.overflowAllocations.length > 0 ? ` ${data.overflowAllocations.length > 0 ? `
<div class="funds-card__allocs"> <div class="flows-card__allocs">
<div class="funds-card__alloc-title">Overflow</div> <div class="flows-card__alloc-title">Overflow</div>
${data.overflowAllocations.map((a) => ` ${data.overflowAllocations.map((a) => `
<div class="funds-card__alloc"> <div class="flows-card__alloc">
<span class="funds-card__alloc-dot" style="background:${a.color}"></span> <span class="flows-card__alloc-dot" style="background:${a.color}"></span>
${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))} ${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
</div> </div>
`).join("")} `).join("")}
</div> </div>
` : ""} ` : ""}
${data.spendingAllocations.length > 0 ? ` ${data.spendingAllocations.length > 0 ? `
<div class="funds-card__allocs"> <div class="flows-card__allocs">
<div class="funds-card__alloc-title">Spending</div> <div class="flows-card__alloc-title">Spending</div>
${data.spendingAllocations.map((a) => ` ${data.spendingAllocations.map((a) => `
<div class="funds-card__alloc"> <div class="flows-card__alloc">
<span class="funds-card__alloc-dot" style="background:${a.color}"></span> <span class="flows-card__alloc-dot" style="background:${a.color}"></span>
${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))} ${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
</div> </div>
`).join("")} `).join("")}
@ -514,24 +519,24 @@ class FolkFundsApp extends HTMLElement {
: "#64748b"; : "#64748b";
return ` return `
<div class="funds-card"> <div class="flows-card">
<div class="funds-card__header"> <div class="flows-card__header">
<span class="funds-card__icon">&#x1F3AF;</span> <span class="flows-card__icon">&#x1F3AF;</span>
<span class="funds-card__label">${this.esc(data.label)}</span> <span class="flows-card__label">${this.esc(data.label)}</span>
<span class="funds-card__status" style="color:${statusColor}">${data.status}</span> <span class="flows-card__status" style="color:${statusColor}">${data.status}</span>
</div> </div>
${data.description ? `<div class="funds-card__desc">${this.esc(data.description)}</div>` : ""} ${data.description ? `<div class="flows-card__desc">${this.esc(data.description)}</div>` : ""}
<div class="funds-card__bar-container"> <div class="flows-card__bar-container">
<div class="funds-card__bar funds-card__bar--outcome" style="width:${fillPct}%;background:${statusColor}"></div> <div class="flows-card__bar flows-card__bar--outcome" style="width:${fillPct}%;background:${statusColor}"></div>
</div> </div>
<div class="funds-card__stats"> <div class="flows-card__stats">
<div> <div>
<span class="funds-card__stat-value">$${Math.floor(data.fundingReceived).toLocaleString()}</span> <span class="flows-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-label">/ $${Math.floor(data.fundingTarget).toLocaleString()}</span>
</div> </div>
<div> <div>
<span class="funds-card__stat-value">${Math.round(fillPct)}%</span> <span class="flows-card__stat-value">${Math.round(fillPct)}%</span>
<span class="funds-card__stat-label">funded</span> <span class="flows-card__stat-label">funded</span>
</div> </div>
</div> </div>
</div>`; </div>`;
@ -547,7 +552,7 @@ class FolkFundsApp extends HTMLElement {
private renderDiagramTab(): string { private renderDiagramTab(): string {
if (this.nodes.length === 0) { 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); 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"; const scoreColor = scorePct >= 90 ? "#fbbf24" : scorePct >= 60 ? "#10b981" : scorePct >= 30 ? "#f59e0b" : "#ef4444";
return ` return `
<div class="funds-canvas-container" id="canvas-container"> <div class="flows-canvas-container" id="canvas-container">
<div class="funds-canvas-badge" id="canvas-badge"> <div class="flows-canvas-badge" id="canvas-badge">
<div> <div>
<div class="funds-canvas-badge__score" id="badge-score" style="color:${scoreColor}">${scorePct}%</div> <div class="flows-canvas-badge__score" id="badge-score" style="color:${scoreColor}">${scorePct}%</div>
<div class="funds-canvas-badge__label">ENOUGH</div> <div class="flows-canvas-badge__label">ENOUGH</div>
</div> </div>
</div> </div>
<div class="funds-canvas-toolbar"> <div class="flows-canvas-toolbar">
<button class="funds-canvas-btn funds-canvas-btn--source" data-canvas-action="add-source">+ Source</button> <button class="flows-canvas-btn flows-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="flows-canvas-btn flows-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> <button class="flows-canvas-btn flows-canvas-btn--outcome" data-canvas-action="add-outcome">+ Outcome</button>
<div class="funds-canvas-sep"></div> <div class="flows-canvas-sep"></div>
<button class="funds-canvas-btn" data-canvas-action="sim" id="sim-btn">${this.isSimulating ? "Pause" : "Play"}</button> <button class="flows-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="flows-canvas-btn" data-canvas-action="fit">Fit</button>
<button class="funds-canvas-btn" data-canvas-action="share">Share</button> <button class="flows-canvas-btn" data-canvas-action="share">Share</button>
</div> </div>
<svg class="funds-canvas-svg" id="flow-canvas"> <svg class="flows-canvas-svg" id="flow-canvas">
<g id="canvas-transform"> <g id="canvas-transform">
<g id="edge-layer"></g> <g id="edge-layer"></g>
<g id="wire-layer"></g> <g id="wire-layer"></g>
<g id="node-layer"></g> <g id="node-layer"></g>
</g> </g>
</svg> </svg>
<div class="funds-editor-panel" id="editor-panel"></div> <div class="flows-editor-panel" id="editor-panel"></div>
<div class="funds-canvas-legend"> <div class="flows-canvas-legend">
<span class="funds-canvas-legend-item"><span class="funds-canvas-legend-dot" style="background:#10b981"></span>Source</span> <span class="flows-canvas-legend-item"><span class="flows-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="flows-canvas-legend-item"><span class="flows-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="flows-canvas-legend-item"><span class="flows-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="flows-canvas-legend-item"><span class="flows-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="flows-canvas-legend-item"><span class="flows-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> <span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#fbbf24"></span>Sufficient</span>
</div> </div>
<div class="funds-canvas-zoom"> <div class="flows-canvas-zoom">
<button class="funds-canvas-btn" data-canvas-action="zoom-in">+</button> <button class="flows-canvas-btn" data-canvas-action="zoom-in">+</button>
<button class="funds-canvas-btn" data-canvas-action="zoom-out">&minus;</button> <button class="flows-canvas-btn" data-canvas-action="zoom-out">&minus;</button>
</div> </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>`; </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 // Keyboard
this._boundKeyDown = (e: KeyboardEvent) => { this._boundKeyDown = (e: KeyboardEvent) => {
// Skip if typing in editor input // Skip if typing in editor input
@ -1665,7 +1736,7 @@ class FolkFundsApp extends HTMLElement {
let apiKey = "STAGING_KEY"; let apiKey = "STAGING_KEY";
let env = "STAGING"; let env = "STAGING";
try { 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`); const res = await fetch(`${base}/api/transak/config`);
if (res.ok) { if (res.ok) {
const cfg = await res.json(); const cfg = await res.json();
@ -1762,20 +1833,20 @@ class FolkFundsApp extends HTMLElement {
const node = this.nodes.find((n) => n.id === nodeId); const node = this.nodes.find((n) => n.id === nodeId);
if (!node) return; 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") { if (node.type === "source") {
const d = node.data as SourceNodeData; 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") { } else if (node.type === "funnel") {
const d = node.data as FunnelNodeData; const d = node.data as FunnelNodeData;
const suf = computeSufficiencyState(d); 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="flows-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" style="text-transform:capitalize;color:${suf === "sufficient" || suf === "abundant" ? "#fbbf24" : "#94a3b8"}">${suf}</div>`;
} else { } else {
const d = node.data as OutcomeNodeData; const d = node.data as OutcomeNodeData;
const pct = d.fundingTarget > 0 ? Math.round((d.fundingReceived / d.fundingTarget) * 100) : 0; 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="flows-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" style="text-transform:capitalize">${d.status}</div>`;
} }
tooltip.innerHTML = html; tooltip.innerHTML = html;
@ -1811,7 +1882,7 @@ class FolkFundsApp extends HTMLElement {
// ─── Node detail modals ────────────────────────────── // ─── Node detail modals ──────────────────────────────
private closeModal() { private closeModal() {
const m = this.shadow.getElementById("funds-modal"); const m = this.shadow.getElementById("flows-modal");
if (m) m.remove(); if (m) m.remove();
} }
@ -1858,8 +1929,8 @@ class FolkFundsApp extends HTMLElement {
<span>${Math.min(phasePct, 100)}% funded</span> <span>${Math.min(phasePct, 100)}% funded</span>
<span>$${Math.floor(Math.min(d.fundingReceived, p.fundingThreshold)).toLocaleString()} / $${p.fundingThreshold.toLocaleString()}</span> <span>$${Math.floor(Math.min(d.fundingReceived, p.fundingThreshold)).toLocaleString()} / $${p.fundingThreshold.toLocaleString()}</span>
</div> </div>
<div class="funds-modal__progress-bar"> <div class="flows-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-fill" style="width:${Math.min(phasePct, 100)}%;background:${unlocked ? "#10b981" : "#3b82f6"}"></div>
</div> </div>
</div> </div>
${p.tasks.map((t, ti) => ` ${p.tasks.map((t, ti) => `
@ -1877,22 +1948,22 @@ class FolkFundsApp extends HTMLElement {
} }
const backdrop = document.createElement("div"); const backdrop = document.createElement("div");
backdrop.className = "funds-modal-backdrop"; backdrop.className = "flows-modal-backdrop";
backdrop.id = "funds-modal"; backdrop.id = "flows-modal";
backdrop.innerHTML = `<div class="funds-modal"> backdrop.innerHTML = `<div class="flows-modal">
<div class="funds-modal__header"> <div class="flows-modal__header">
<div style="display:flex;align-items:center;gap:10px"> <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="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> <span style="font-size:16px;font-weight:700;color:#e2e8f0">${this.esc(d.label)}</span>
</div> </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>
${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>` : ""} ${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="margin-bottom:20px">
<div style="font-size:28px;font-weight:700;color:#e2e8f0">$${Math.floor(d.fundingReceived).toLocaleString()}</div> <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 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="flows-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-fill" style="width:${fillPct}%;background:${statusColor}"></div>
</div> </div>
</div> </div>
${d.phases && d.phases.length > 0 ? `<div style="margin-bottom:16px"> ${d.phases && d.phases.length > 0 ? `<div style="margin-bottom:16px">
@ -2018,15 +2089,15 @@ class FolkFundsApp extends HTMLElement {
} }
const backdrop = document.createElement("div"); const backdrop = document.createElement("div");
backdrop.className = "funds-modal-backdrop"; backdrop.className = "flows-modal-backdrop";
backdrop.id = "funds-modal"; backdrop.id = "flows-modal";
backdrop.innerHTML = `<div class="funds-modal"> backdrop.innerHTML = `<div class="flows-modal">
<div class="funds-modal__header"> <div class="flows-modal__header">
<div style="display:flex;align-items:center;gap:10px"> <div style="display:flex;align-items:center;gap:10px">
<span style="font-size:20px">${icons[d.sourceType] || "&#x1F4B0;"}</span> <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> <span style="font-size:16px;font-weight:700;color:#e2e8f0">${this.esc(d.label)}</span>
</div> </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>
<div style="margin-bottom:16px"> <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> <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 ──────────────────────────────────────── // ─── River tab ────────────────────────────────────────
private renderRiverTab(): string { 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() { private mountRiver() {
@ -2266,9 +2337,9 @@ class FolkFundsApp extends HTMLElement {
if (!mount) return; if (!mount) return;
// Check if already mounted // 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"); river.setAttribute("simulate", "true");
mount.appendChild(river); mount.appendChild(river);
@ -2285,38 +2356,38 @@ class FolkFundsApp extends HTMLElement {
private renderTransactionsTab(): string { private renderTransactionsTab(): string {
if (this.isDemo) { if (this.isDemo) {
return ` return `
<div class="funds-tx-empty"> <div class="flows-tx-empty">
<p>Transaction history is not available in demo mode.</p> <p>Transaction history is not available in demo mode.</p>
</div>`; </div>`;
} }
if (!this.txLoaded) { if (!this.txLoaded) {
return '<div class="funds-loading">Loading transactions...</div>'; return '<div class="flows-loading">Loading transactions...</div>';
} }
if (this.transactions.length === 0) { if (this.transactions.length === 0) {
return ` return `
<div class="funds-tx-empty"> <div class="flows-tx-empty">
<p>No transactions yet for this flow.</p> <p>No transactions yet for this flow.</p>
</div>`; </div>`;
} }
return ` return `
<div class="funds-tx-list"> <div class="flows-tx-list">
${this.transactions.map((tx) => ` ${this.transactions.map((tx) => `
<div class="funds-tx"> <div class="flows-tx">
<div class="funds-tx__icon">${tx.type === "deposit" ? "&#x2B06;" : tx.type === "withdraw" ? "&#x2B07;" : "&#x1F504;"}</div> <div class="flows-tx__icon">${tx.type === "deposit" ? "&#x2B06;" : tx.type === "withdraw" ? "&#x2B07;" : "&#x1F504;"}</div>
<div class="funds-tx__body"> <div class="flows-tx__body">
<div class="funds-tx__desc">${this.esc(tx.description || tx.type)}</div> <div class="flows-tx__desc">${this.esc(tx.description || tx.type)}</div>
<div class="funds-tx__meta"> <div class="flows-tx__meta">
${tx.from ? `From: ${this.esc(tx.from)}` : ""} ${tx.from ? `From: ${this.esc(tx.from)}` : ""}
${tx.to ? ` &rarr; ${this.esc(tx.to)}` : ""} ${tx.to ? ` &rarr; ${this.esc(tx.to)}` : ""}
</div> </div>
</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()} ${tx.type === "deposit" ? "+" : "-"}$${Math.abs(tx.amount).toLocaleString()}
</div> </div>
<div class="funds-tx__time">${this.formatTime(tx.timestamp)}</div> <div class="flows-tx__time">${this.formatTime(tx.timestamp)}</div>
</div> </div>
`).join("")} `).join("")}
</div>`; </div>`;
@ -2415,7 +2486,7 @@ class FolkFundsApp extends HTMLElement {
if (flowId) { if (flowId) {
const detailUrl = this.getApiBase() const detailUrl = this.getApiBase()
? `${this.getApiBase()}/flow/${encodeURIComponent(flowId)}` ? `${this.getApiBase()}/flow/${encodeURIComponent(flowId)}`
: `/rfunds/flow/${encodeURIComponent(flowId)}`; : `/rflows/flow/${encodeURIComponent(flowId)}`;
window.location.href = detailUrl; window.location.href = detailUrl;
return; 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. * rFlows rich landing page body.
* Ported from rfunds-online/app/page.tsx (Next.js/Tailwind). * Ported from rflows-online/app/page.tsx (Next.js/Tailwind).
* Returned by landingPage() in the module export; * Returned by landingPage() in the module export;
* the shell wraps it with header, CSS, and analytics. * the shell wraps it with header, CSS, and analytics.
*/ */
@ -8,7 +8,7 @@ export function renderLanding(): string {
return ` return `
<!-- Hero --> <!-- Hero -->
<div class="rl-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"> <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 Threshold-Based Flow Funding
</h1> </h1>
@ -17,7 +17,7 @@ export function renderLanding(): string {
Connect funnels, set overflow rules, and track outcomes in real-time. Connect funnels, set overflow rules, and track outcomes in real-time.
</p> </p>
<div class="rl-cta-row"> <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> <a href="/create-space" class="rl-cta-primary">Create Your Own</a>
</div> </div>
</div> </div>
@ -120,7 +120,7 @@ export function renderLanding(): string {
<div class="rl-container"> <div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Ecosystem Integration</h2> <h2 class="rl-heading" style="text-align:center">Ecosystem Integration</h2>
<p class="rl-subtext" style="text-align:center"> <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> </p>
<div class="rl-grid-2" style="max-width:700px;margin:0 auto"> <div class="rl-grid-2" style="max-width:700px;margin:0 auto">
<div class="rl-integration"> <div class="rl-integration">
@ -145,7 +145,7 @@ export function renderLanding(): string {
<section class="rl-section rl-section--alt"> <section class="rl-section rl-section--alt">
<div class="rl-container"> <div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2> <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-grid-3">
<div class="rl-card rl-card--center"> <div class="rl-card rl-card--center">
<div class="rl-icon-box">&#127754;</div> <div class="rl-icon-box">&#127754;</div>
@ -170,7 +170,7 @@ export function renderLanding(): string {
<section class="rl-section"> <section class="rl-section">
<div class="rl-container" style="text-align:center"> <div class="rl-container" style="text-align:center">
<h2 class="rl-heading">Your Data, Protected</h2> <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-grid-3">
<div class="rl-card rl-card--center"> <div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128274;</div> <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> <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> <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"> <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> <a href="/create-space" class="rl-cta-secondary">Create Your Own</a>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
/** /**
* Maps TBFF API response data to FlowNode[] for visualization. * 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"; 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"; import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData } from "./types";

View File

@ -1,6 +1,6 @@
/** /**
* Flow simulation engine pure function, no framework dependencies. * 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"; 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 { 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. * Wraps the shared local-first stack for space-flow associations.
* Actual flow logic stays in the external payment-flow service. * 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 { EncryptedDocStore } from '../../shared/local-first/storage';
import { DocSyncManager } from '../../shared/local-first/sync'; import { DocSyncManager } from '../../shared/local-first/sync';
import { DocCrypto } from '../../shared/local-first/crypto'; import { DocCrypto } from '../../shared/local-first/crypto';
import { fundsSchema, fundsDocId } from './schemas'; import { flowsSchema, flowsDocId } from './schemas';
import type { FundsDoc, SpaceFlow } from './schemas'; import type { FlowsDoc, SpaceFlow } from './schemas';
export class FundsLocalFirstClient { export class FlowsLocalFirstClient {
#space: string; #space: string;
#documents: DocumentManager; #documents: DocumentManager;
#store: EncryptedDocStore; #store: EncryptedDocStore;
@ -29,7 +29,7 @@ export class FundsLocalFirstClient {
documents: this.#documents, documents: this.#documents,
store: this.#store, store: this.#store,
}); });
this.#documents.registerSchema(fundsSchema); this.#documents.registerSchema(flowsSchema);
} }
get isConnected(): boolean { return this.#sync.isConnected; } get isConnected(): boolean { return this.#sync.isConnected; }
@ -37,53 +37,53 @@ export class FundsLocalFirstClient {
async init(): Promise<void> { async init(): Promise<void> {
if (this.#initialized) return; if (this.#initialized) return;
await this.#store.open(); 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); const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) { 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); await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; 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; this.#initialized = true;
} }
async subscribe(): Promise<FundsDoc | null> { async subscribe(): Promise<FlowsDoc | null> {
const docId = fundsDocId(this.#space) as DocumentId; const docId = flowsDocId(this.#space) as DocumentId;
let doc = this.#documents.get<FundsDoc>(docId); let doc = this.#documents.get<FlowsDoc>(docId);
if (!doc) { if (!doc) {
const binary = await this.#store.load(docId); const binary = await this.#store.load(docId);
doc = binary doc = binary
? this.#documents.open<FundsDoc>(docId, fundsSchema, binary) ? this.#documents.open<FlowsDoc>(docId, flowsSchema, binary)
: this.#documents.open<FundsDoc>(docId, fundsSchema); : this.#documents.open<FlowsDoc>(docId, flowsSchema);
} }
await this.#sync.subscribe([docId]); await this.#sync.subscribe([docId]);
return doc ?? null; return doc ?? null;
} }
getFlows(): FundsDoc | undefined { getFlows(): FlowsDoc | undefined {
return this.#documents.get<FundsDoc>(fundsDocId(this.#space) as DocumentId); return this.#documents.get<FlowsDoc>(flowsDocId(this.#space) as DocumentId);
} }
addSpaceFlow(flow: SpaceFlow): void { addSpaceFlow(flow: SpaceFlow): void {
const docId = fundsDocId(this.#space) as DocumentId; const docId = flowsDocId(this.#space) as DocumentId;
this.#sync.change<FundsDoc>(docId, `Add flow ${flow.flowId}`, (d) => { this.#sync.change<FlowsDoc>(docId, `Add flow ${flow.flowId}`, (d) => {
d.spaceFlows[flow.id] = flow; d.spaceFlows[flow.id] = flow;
}); });
} }
removeSpaceFlow(flowId: string): void { removeSpaceFlow(flowId: string): void {
const docId = fundsDocId(this.#space) as DocumentId; const docId = flowsDocId(this.#space) as DocumentId;
this.#sync.change<FundsDoc>(docId, `Remove flow ${flowId}`, (d) => { this.#sync.change<FlowsDoc>(docId, `Remove flow ${flowId}`, (d) => {
for (const [id, sf] of Object.entries(d.spaceFlows)) { for (const [id, sf] of Object.entries(d.spaceFlows)) {
if (sf.flowId === flowId) delete d.spaceFlows[id]; if (sf.flowId === flowId) delete d.spaceFlows[id];
} }
}); });
} }
onChange(cb: (doc: FundsDoc) => void): () => void { onChange(cb: (doc: FlowsDoc) => void): () => void {
return this.#sync.onChange(fundsDocId(this.#space) as DocumentId, cb as (doc: any) => void); return this.#sync.onChange(flowsDocId(this.#space) as DocumentId, cb as (doc: any) => void);
} }
onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } 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"; import { Hono } from "hono";
@ -12,18 +12,18 @@ import { getModuleInfoList } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
import { renderLanding } from "./landing"; import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server'; 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; let _syncServer: SyncServer | null = null;
const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010"; const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010";
function ensureDoc(space: string): FundsDoc { function ensureDoc(space: string): FlowsDoc {
const docId = fundsDocId(space); const docId = flowsDocId(space);
let doc = _syncServer!.getDoc<FundsDoc>(docId); let doc = _syncServer!.getDoc<FlowsDoc>(docId);
if (!doc) { if (!doc) {
doc = Automerge.change(Automerge.init<FundsDoc>(), 'init', (d) => { doc = Automerge.change(Automerge.init<FlowsDoc>(), 'init', (d) => {
const init = fundsSchema.init(); const init = flowsSchema.init();
d.meta = init.meta; d.meta = init.meta;
d.meta.spaceSlug = space; d.meta.spaceSlug = space;
d.spaceFlows = {}; d.spaceFlows = {};
@ -224,9 +224,9 @@ routes.post("/api/space-flows", async (c) => {
const { space, flowId } = await c.req.json(); const { space, flowId } = await c.req.json();
if (!space || !flowId) return c.json({ error: "space and flowId required" }, 400); if (!space || !flowId) return c.json({ error: "space and flowId required" }, 400);
const docId = fundsDocId(space); const docId = flowsDocId(space);
ensureDoc(space); ensureDoc(space);
_syncServer!.changeDoc<FundsDoc>(docId, 'add space flow', (d) => { _syncServer!.changeDoc<FlowsDoc>(docId, 'add space flow', (d) => {
const key = `${space}:${flowId}`; const key = `${space}:${flowId}`;
if (!d.spaceFlows[key]) { if (!d.spaceFlows[key]) {
d.spaceFlows[key] = { id: key, spaceSlug: space, flowId, addedBy: claims.sub, createdAt: Date.now() }; 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") || ""; const space = c.req.query("space") || "";
if (!space) return c.json({ error: "space query param required" }, 400); if (!space) return c.json({ error: "space query param required" }, 400);
const docId = fundsDocId(space); const docId = flowsDocId(space);
const doc = _syncServer!.getDoc<FundsDoc>(docId); const doc = _syncServer!.getDoc<FlowsDoc>(docId);
if (doc) { if (doc) {
const key = `${space}:${flowId}`; const key = `${space}:${flowId}`;
if (doc.spaceFlows[key]) { if (doc.spaceFlows[key]) {
_syncServer!.changeDoc<FundsDoc>(docId, 'remove space flow', (d) => { _syncServer!.changeDoc<FlowsDoc>(docId, 'remove space flow', (d) => {
delete d.spaceFlows[key]; delete d.spaceFlows[key];
}); });
} }
@ -260,25 +260,25 @@ routes.delete("/api/space-flows/:flowId", async (c) => {
// ─── Page routes ──────────────────────────────────────── // ─── 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 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/rflows/folk-flows-app.js"></script>
<script type="module" src="/modules/rfunds/folk-budget-river.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) // Landing page (also serves demo via centralized /demo → space="demo" rewrite)
routes.get("/", (c) => { routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo"; const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({ return c.html(renderShell({
title: `${spaceSlug} — Funds | rSpace`, title: `${spaceSlug} — Flows | rSpace`,
moduleId: "rfunds", moduleId: "rflows",
spaceSlug, spaceSlug,
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-funds-app space="${spaceSlug}"${spaceSlug === "demo" ? ' mode="demo"' : ''}></folk-funds-app>`, body: `<folk-flows-app space="${spaceSlug}"${spaceSlug === "demo" ? ' mode="demo"' : ''}></folk-flows-app>`,
scripts: fundsScripts, scripts: flowsScripts,
styles: fundsStyles, styles: flowsStyles,
})); }));
}); });
@ -287,54 +287,54 @@ routes.get("/flow/:flowId", (c) => {
const spaceSlug = c.req.param("space") || "demo"; const spaceSlug = c.req.param("space") || "demo";
const flowId = c.req.param("flowId"); const flowId = c.req.param("flowId");
return c.html(renderShell({ return c.html(renderShell({
title: `Flow — rFunds | rSpace`, title: `Flow — rFlows | rSpace`,
moduleId: "rfunds", moduleId: "rflows",
spaceSlug, spaceSlug,
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
styles: fundsStyles, styles: flowsStyles,
body: `<folk-funds-app space="${spaceSlug}" flow-id="${flowId}"></folk-funds-app>`, body: `<folk-flows-app space="${spaceSlug}" flow-id="${flowId}"></folk-flows-app>`,
scripts: fundsScripts, scripts: flowsScripts,
})); }));
}); });
// ── Seed template data ── // ── Seed template data ──
function seedTemplateFunds(space: string) { function seedTemplateFlows(space: string) {
if (!_syncServer) return; if (!_syncServer) return;
const doc = ensureDoc(space); const doc = ensureDoc(space);
if (Object.keys(doc.spaceFlows).length > 0) return; if (Object.keys(doc.spaceFlows).length > 0) return;
const docId = fundsDocId(space); const docId = flowsDocId(space);
const now = Date.now(); const now = Date.now();
const flowId = crypto.randomUUID(); const flowId = crypto.randomUUID();
// Create a SpaceFlow entry pointing to "demo" — the frontend // Create a SpaceFlow entry pointing to "demo" — the frontend
// already renders demoNodes from presets.ts in demo mode. // 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] = { d.spaceFlows[flowId] = {
id: flowId, spaceSlug: space, flowId: 'demo', id: flowId, spaceSlug: space, flowId: 'demo',
addedBy: 'did:demo:seed', createdAt: now, 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 = { export const flowsModule: RSpaceModule = {
id: "rfunds", id: "rflows",
name: "rFunds", name: "rFlows",
icon: "🌊", icon: "🌊",
description: "Budget flows, river visualization, and treasury management", description: "Budget flows, river visualization, and treasury management",
scoping: { defaultScope: 'space', userConfigurable: false }, 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, routes,
landingPage: renderLanding, landingPage: renderLanding,
seedTemplate: seedTemplateFunds, seedTemplate: seedTemplateFlows,
async onInit(ctx) { async onInit(ctx) {
_syncServer = ctx.syncServer; _syncServer = ctx.syncServer;
}, },
standaloneDomain: "rfunds.online", standaloneDomain: "rflows.online",
feeds: [ feeds: [
{ {
id: "treasury-flows", 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). * 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. * Actual flow logic stays in the external payment-flow service.
* This doc tracks which flows are associated with which spaces. * This doc tracks which flows are associated with which spaces.
@ -20,7 +20,7 @@ export interface SpaceFlow {
createdAt: number; createdAt: number;
} }
export interface FundsDoc { export interface FlowsDoc {
meta: { meta: {
module: string; module: string;
collection: string; collection: string;
@ -33,14 +33,14 @@ export interface FundsDoc {
// ── Schema registration ── // ── Schema registration ──
export const fundsSchema: DocSchema<FundsDoc> = { export const flowsSchema: DocSchema<FlowsDoc> = {
module: 'funds', module: 'flows',
collection: 'flows', collection: 'data',
version: 1, version: 1,
init: (): FundsDoc => ({ init: (): FlowsDoc => ({
meta: { meta: {
module: 'funds', module: 'flows',
collection: 'flows', collection: 'data',
version: 1, version: 1,
spaceSlug: '', spaceSlug: '',
createdAt: Date.now(), createdAt: Date.now(),
@ -51,6 +51,6 @@ export const fundsSchema: DocSchema<FundsDoc> = {
// ── Helpers ── // ── Helpers ──
export function fundsDocId(space: string) { export function flowsDocId(space: string) {
return `${space}:funds:flows` as const; return `${space}:flows:data` as const;
} }

View File

@ -157,7 +157,7 @@ class FolkNotesApp extends HTMLElement {
Accommodation: EUR 1200 (30%) Accommodation: EUR 1200 (30%)
Activities: EUR 1000 (25%) Activities: EUR 1000 (25%)
Food: EUR 600 (15%) 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_plain: "Pre-trip preparation checklist covering flights, transfers, travel documents, and budget overview for the Alpine Explorer 2026 trip.",
content_format: 'html', content_format: 'html',
type: "NOTE", tags: ["planning", "budget", "transport"], is_pinned: true, 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", 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"], tags: ["cosmolocal", "governance"],
}, },
{ {
@ -181,7 +181,7 @@ function seedDemoIfEmpty(space: string) {
}, },
{ {
nbId: nb2Id, nbDocId: nb2DocId, title: "Weekly Standup — Feb 15, 2026", 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"], tags: ["standup"],
}, },
{ {

View File

@ -483,13 +483,13 @@ function seedTemplateTrips(space: string) {
d.destinations[dest2Id] = { d.destinations[dest2Id] = {
id: dest2Id, tripId, name: 'Athens', country: 'Greece', id: dest2Id, tripId, name: 'Athens', country: 'Greece',
lat: 37.9838, lng: 23.7275, arrivalDate: '2026-04-16', departureDate: '2026-04-18', 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 = {}; d.itinerary = {};
const itin = [ 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: '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: 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++) { for (let i = 0; i < itin.length; i++) {
const iId = crypto.randomUUID(); const iId = crypto.randomUUID();

View File

@ -90,7 +90,7 @@ export function renderLanding(): string {
<div class="rl-integration"> <div class="rl-integration">
<div class="rl-icon-box">&#128200;</div> <div class="rl-icon-box">&#128200;</div>
<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> <p>Overlay budget categories on your wallet data to see where funds are allocated and how they flow.</p>
</div> </div>
</div> </div>

View File

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

View File

@ -113,7 +113,7 @@ const TEMPLATE_SHAPES: Record<string, unknown>[] = [
store: "", store: "",
}, },
// ─── rFunds: Budget ───────────────────────────────────────── // ─── rFlows: Budget ─────────────────────────────────────────
{ {
id: "tmpl-budget", id: "tmpl-budget",
type: "folk-budget", type: "folk-budget",
@ -129,7 +129,7 @@ const TEMPLATE_SHAPES: Record<string, unknown>[] = [
], ],
}, },
// ─── rFunds: Expense ──────────────────────────────────────── // ─── rFlows: Expense ────────────────────────────────────────
{ {
id: "tmpl-expense", id: "tmpl-expense",
type: "demo-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 rchoices: { badge: "rCo", color: "#f0abfc" }, // fuchsia-300
rvote: { badge: "rV", color: "#c4b5fd" }, // violet-300 rvote: { badge: "rV", color: "#c4b5fd" }, // violet-300
// Funding & Commerce // Funding & Commerce
rfunds: { badge: "rF", color: "#bef264" }, // lime-300 rflows: { badge: "rFl", color: "#bef264" }, // lime-300
rwallet: { badge: "rW", color: "#fde047" }, // yellow-300 rwallet: { badge: "rW", color: "#fde047" }, // yellow-300
rcart: { badge: "rCt", color: "#fdba74" }, // orange-300 rcart: { badge: "rCt", color: "#fdba74" }, // orange-300
rauctions: { badge: "rA", color: "#fca5a5" }, // red-300 rauctions: { badge: "rA", color: "#fca5a5" }, // red-300
@ -75,7 +75,7 @@ const MODULE_CATEGORIES: Record<string, string> = {
rforum: "Communicating", rforum: "Communicating",
rchoices: "Deciding", rchoices: "Deciding",
rvote: "Deciding", rvote: "Deciding",
rfunds: "Funding & Commerce", rflows: "Funding & Commerce",
rwallet: "Funding & Commerce", rwallet: "Funding & Commerce",
rcart: "Funding & Commerce", rcart: "Funding & Commerce",
rauctions: "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" }, rforum: { badge: "rFo", color: "#fcd34d" },
rchoices: { badge: "rCo", color: "#f0abfc" }, rchoices: { badge: "rCo", color: "#f0abfc" },
rvote: { badge: "rV", color: "#c4b5fd" }, rvote: { badge: "rV", color: "#c4b5fd" },
rfunds: { badge: "rF", color: "#bef264" }, rflows: { badge: "rFl", color: "#bef264" },
rwallet: { badge: "rW", color: "#fde047" }, rwallet: { badge: "rW", color: "#fde047" },
rcart: { badge: "rCt", color: "#fdba74" }, rcart: { badge: "rCt", color: "#fdba74" },
rauctions: { badge: "rA", color: "#fca5a5" }, rauctions: { badge: "rA", color: "#fca5a5" },
@ -60,7 +60,7 @@ const MODULE_CATEGORIES: Record<string, string> = {
rcal: "Planning", rtrips: "Planning", rmaps: "Planning", rcal: "Planning", rtrips: "Planning", rmaps: "Planning",
rchats: "Communicating", rinbox: "Communicating", rmail: "Communicating", rforum: "Communicating", rchats: "Communicating", rinbox: "Communicating", rmail: "Communicating", rforum: "Communicating",
rchoices: "Deciding", rvote: "Deciding", 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", rphotos: "Sharing", rnetwork: "Sharing", rsocials: "Sharing", rfiles: "Sharing", rbooks: "Sharing",
rdata: "Observing", rdata: "Observing",
rwork: "Work & Productivity", rwork: "Work & Productivity",

View File

@ -214,51 +214,51 @@ export default defineConfig({
resolve(__dirname, "dist/modules/rchoices/choices.css"), resolve(__dirname, "dist/modules/rchoices/choices.css"),
); );
// Build funds module components // Build flows module components
const fundsAlias = { const flowsAlias = {
"../lib/types": resolve(__dirname, "modules/rfunds/lib/types.ts"), "../lib/types": resolve(__dirname, "modules/rflows/lib/types.ts"),
"../lib/simulation": resolve(__dirname, "modules/rfunds/lib/simulation.ts"), "../lib/simulation": resolve(__dirname, "modules/rflows/lib/simulation.ts"),
"../lib/presets": resolve(__dirname, "modules/rfunds/lib/presets.ts"), "../lib/presets": resolve(__dirname, "modules/rflows/lib/presets.ts"),
"../lib/map-flow": resolve(__dirname, "modules/rfunds/lib/map-flow.ts"), "../lib/map-flow": resolve(__dirname, "modules/rflows/lib/map-flow.ts"),
}; };
await build({ await build({
configFile: false, configFile: false,
root: resolve(__dirname, "modules/rfunds/components"), root: resolve(__dirname, "modules/rflows/components"),
resolve: { alias: fundsAlias }, resolve: { alias: flowsAlias },
build: { build: {
emptyOutDir: false, emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rfunds"), outDir: resolve(__dirname, "dist/modules/rflows"),
lib: { lib: {
entry: resolve(__dirname, "modules/rfunds/components/folk-budget-river.ts"), entry: resolve(__dirname, "modules/rflows/components/folk-flow-river.ts"),
formats: ["es"], 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({ await build({
configFile: false, configFile: false,
root: resolve(__dirname, "modules/rfunds/components"), root: resolve(__dirname, "modules/rflows/components"),
resolve: { alias: fundsAlias }, resolve: { alias: flowsAlias },
build: { build: {
emptyOutDir: false, emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rfunds"), outDir: resolve(__dirname, "dist/modules/rflows"),
lib: { lib: {
entry: resolve(__dirname, "modules/rfunds/components/folk-funds-app.ts"), entry: resolve(__dirname, "modules/rflows/components/folk-flows-app.ts"),
formats: ["es"], 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 // Copy flows CSS
mkdirSync(resolve(__dirname, "dist/modules/rfunds"), { recursive: true }); mkdirSync(resolve(__dirname, "dist/modules/rflows"), { recursive: true });
copyFileSync( copyFileSync(
resolve(__dirname, "modules/rfunds/components/funds.css"), resolve(__dirname, "modules/rflows/components/flows.css"),
resolve(__dirname, "dist/modules/rfunds/funds.css"), resolve(__dirname, "dist/modules/rflows/flows.css"),
); );
// Build files module component // Build files module component
@ -797,7 +797,7 @@ export default defineConfig({
}); });
// Build demo scripts for each module that has one // 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) { for (const mod of demoModules) {
const dir = `r${mod}`; const dir = `r${mod}`;
const demoEntry = resolve(__dirname, `modules/${dir}/components/${mod}-demo.ts`); 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-spider-3d" title="3D Spider Plot">📊 3D Spider</button>
<button id="new-conviction" title="Conviction Ranking">⏳ Conviction</button> <button id="new-conviction" title="Conviction Ranking">⏳ Conviction</button>
<button id="new-token" title="New Token">🪙 Token</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-wallet" title="Embed rWallet">💰 rWallet</button>
<button id="embed-vote" title="Embed rVote">🗳️ rVote</button> <button id="embed-vote" title="Embed rVote">🗳️ rVote</button>
</div> </div>
@ -3733,7 +3733,7 @@
{ btnId: "embed-forum", moduleId: "rforum" }, { btnId: "embed-forum", moduleId: "rforum" },
{ btnId: "embed-inbox", moduleId: "rinbox" }, { btnId: "embed-inbox", moduleId: "rinbox" },
{ btnId: "embed-tube", moduleId: "rtube" }, { btnId: "embed-tube", moduleId: "rtube" },
{ btnId: "embed-funds", moduleId: "rfunds" }, { btnId: "embed-flows", moduleId: "rflows" },
{ btnId: "embed-wallet", moduleId: "rwallet" }, { btnId: "embed-wallet", moduleId: "rwallet" },
{ btnId: "embed-vote", moduleId: "rvote" }, { btnId: "embed-vote", moduleId: "rvote" },
{ btnId: "embed-cart", moduleId: "rcart" }, { btnId: "embed-cart", moduleId: "rcart" },

View File

@ -359,7 +359,7 @@
Every community rSpace comes with a full suite of interoperable tools &mdash; Every community rSpace comes with a full suite of interoperable tools &mdash;
voting, budgets, maps, files, notes, and more &mdash; all sharing the same 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 <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 community uses. No separate accounts, no OAuth redirects, no third-party identity
providers. Your community, your identity, your data. providers. Your community, your identity, your data.
</p> </p>
@ -429,7 +429,7 @@
v v
┌─────────── rSpace CRDT Sync Layer ───────────┐ ┌─────────── rSpace CRDT Sync Layer ───────────┐
| | | |
| rVote rFunds rFiles rNotes rMaps ... | | rVote rFlows rFiles rNotes rMaps ... |
| | | | | | | | | | | | | |
| └───────┴───────┴───────┴───────┘ | | └───────┴───────┴───────┴───────┘ |
| shared community data graph | | shared community data graph |
@ -444,7 +444,7 @@
<div class="rl-card" style="text-align: left;"> <div class="rl-card" style="text-align: left;">
<h3>Your data, connected across tools</h3> <h3>Your data, connected across tools</h3>
<p> <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> 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 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 <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/rfiles" class="ecosystem-app">📁 rFiles</a>
<a href="https://rspace.online/rnotes" class="ecosystem-app">📝 rNotes</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/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/rnetwork" class="ecosystem-app">🕸️ rNetwork</a>
<a href="https://rspace.online/rcart" class="ecosystem-app">🛒 rCart</a> <a href="https://rspace.online/rcart" class="ecosystem-app">🛒 rCart</a>
<a href="https://rspace.online/rtube" class="ecosystem-app">🎬 rTube</a> <a href="https://rspace.online/rtube" class="ecosystem-app">🎬 rTube</a>

File diff suppressed because one or more lines are too long