Merge branch 'dev'
This commit is contained in:
commit
453fd7e6b4
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 : [];
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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">💰</div>
|
<div class="rl-icon-box">💰</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> — automatic routing by capability, location, and cost</li>
|
<li><strong>Provider matching</strong> — automatic routing by capability, location, and cost</li>
|
||||||
<li><strong>Revenue splits</strong> — creator, community, and provider shares via rFunds</li>
|
<li><strong>Revenue splits</strong> — creator, community, and provider shares via rFlows</li>
|
||||||
<li><strong>Order tracking</strong> — real-time status from accepted to delivered</li>
|
<li><strong>Order tracking</strong> — real-time status from accepted to delivered</li>
|
||||||
<li><strong>Volume pricing</strong> — automatic tier detection from pooled orders</li>
|
<li><strong>Volume pricing</strong> — automatic tier detection from pooled orders</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 —
|
Funnels fill to their threshold, then overflow routes surplus to the next layer —
|
||||||
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">💰</div>
|
<div class="flows-features__icon">💰</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">🏛</div>
|
<div class="flows-features__icon">🏛</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">🎯</div>
|
<div class="flows-features__icon">🎯</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">🌊</div>
|
<div class="flows-features__icon">🌊</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">✨</div>
|
<div class="flows-features__icon">✨</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 — grants, donations, sales, or any recurring income — with allocation splits.</p>
|
<p>Add revenue streams — grants, donations, sales, or any recurring income — 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 ? ` · ${f.outcomeCount} outcomes` : ""}
|
${f.outcomeCount != null ? ` · ${f.outcomeCount} outcomes` : ""}
|
||||||
${f.status ? ` · ${f.status}` : ""}
|
${f.status ? ` · ${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">← Flows</a>
|
<a href="${this.esc(backUrl)}" class="rapp-nav__back">← 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">💰</span>
|
<span class="flows-card__icon">💰</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}% → ${this.esc(this.getNodeLabel(a.targetId))}
|
${a.percentage}% → ${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">🏛</span>
|
<span class="flows-card__icon">🏛</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}% → ${this.esc(this.getNodeLabel(a.targetId))}
|
${a.percentage}% → ${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}% → ${this.esc(this.getNodeLabel(a.targetId))}
|
${a.percentage}% → ${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">🎯</span>
|
<span class="flows-card__icon">🎯</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">−</button>
|
<button class="flows-canvas-btn" data-canvas-action="zoom-out">−</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 · ${d.sourceType}</div>`;
|
html += `<div class="flows-node-tooltip__stat">$${d.flowRate.toLocaleString()}/mo · ${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">×</button>
|
<button class="flows-modal__close" data-modal-action="close">×</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] || "💰"}</span>
|
<span style="font-size:20px">${icons[d.sourceType] || "💰"}</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">×</button>
|
<button class="flows-modal__close" data-modal-action="close">×</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" ? "⬆" : tx.type === "withdraw" ? "⬇" : "🔄"}</div>
|
<div class="flows-tx__icon">${tx.type === "deposit" ? "⬆" : tx.type === "withdraw" ? "⬇" : "🔄"}</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 ? ` → ${this.esc(tx.to)}` : ""}
|
${tx.to ? ` → ${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);
|
||||||
|
|
|
||||||
|
|
@ -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">🌊</div>
|
<div class="rl-icon-box">🌊</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">🔒</div>
|
<div class="rl-icon-box">🔒</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>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ export function renderLanding(): string {
|
||||||
<div class="rl-integration">
|
<div class="rl-integration">
|
||||||
<div class="rl-icon-box">📈</div>
|
<div class="rl-icon-box">📈</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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -359,7 +359,7 @@
|
||||||
Every community rSpace comes with a full suite of interoperable tools —
|
Every community rSpace comes with a full suite of interoperable tools —
|
||||||
voting, budgets, maps, files, notes, and more — all sharing the same
|
voting, budgets, maps, files, notes, and more — 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
Loading…
Reference in New Issue