diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css
index e025e41..c346011 100644
--- a/modules/rflows/components/flows.css
+++ b/modules/rflows/components/flows.css
@@ -86,20 +86,7 @@
/* ── Detail view ─────────────────────────────────────── */
.flows-detail { max-width: 1100px; margin: 0 auto; padding: 16px 20px 64px; }
-
-/* ── Tabs ────────────────────────────────────────────── */
-.flows-tabs {
- display: flex; gap: 4px; border-bottom: 1px solid #1e293b; margin-bottom: 20px;
-}
-.flows-tab {
- padding: 8px 18px; border: none; border-bottom: 2px solid transparent;
- background: transparent; color: #64748b; font-size: 13px; font-weight: 500;
- cursor: pointer; transition: color 0.2s, border-color 0.2s;
-}
-.flows-tab:hover { color: #e2e8f0; }
-.flows-tab--active { color: #e2e8f0; border-bottom-color: #6366f1; }
-
-.flows-tab-content { min-height: 300px; }
+.flows-detail--fullpage { padding: 0; max-width: none; height: 100vh; }
/* ── Table tab — card grid ───────────────────────────── */
.flows-table { }
@@ -159,6 +146,25 @@
background: #0f172a; border-radius: 12px; border: 1px solid #334155;
overflow: hidden; user-select: none; touch-action: none;
}
+.flows-canvas-container--fullpage {
+ height: 100%; border-radius: 0; border: none; min-height: unset;
+}
+
+/* Compact nav overlay inside full-page canvas */
+.flows-nav-overlay {
+ position: absolute; top: 0; left: 0; right: 0; z-index: 15;
+ height: 44px; display: flex; align-items: center; padding: 0 16px; gap: 12px;
+ background: linear-gradient(to bottom, rgba(15,23,42,0.9) 0%, transparent 100%);
+ pointer-events: none;
+}
+.flows-nav-overlay > * { pointer-events: auto; }
+.flows-nav-overlay .rapp-nav__back { color: #94a3b8; text-decoration: none; font-size: 13px; }
+.flows-nav-overlay .rapp-nav__back:hover { color: #e2e8f0; }
+.flows-nav-overlay .rapp-nav__title { color: #e2e8f0; font-size: 14px; font-weight: 600; }
+.flows-nav-overlay .rapp-nav__badge { font-size: 10px; color: #fbbf24; background: rgba(251,191,36,0.15); padding: 2px 8px; border-radius: 4px; }
+
+/* Badge offset when nav overlay present */
+.flows-canvas-container--fullpage .flows-canvas-badge { top: 54px; }
.flows-canvas-svg {
width: 100%; height: 100%; display: block;
@@ -214,6 +220,33 @@
}
.editor-close:hover { color: #e2e8f0; }
+/* Analytics popout panel (left side) */
+.flows-analytics-panel {
+ position: absolute; top: 0; left: 0; bottom: 0; width: 380px; z-index: 20;
+ background: #1e293b; border-right: 1px solid #334155;
+ transform: translateX(-100%); transition: transform 0.25s ease;
+ overflow-y: auto; display: flex; flex-direction: column;
+}
+.flows-analytics-panel.open { transform: translateX(0); }
+
+.analytics-header {
+ display: flex; align-items: center; gap: 8px; padding: 12px 16px;
+ border-bottom: 1px solid #334155; flex-shrink: 0;
+}
+.analytics-title { font-size: 14px; font-weight: 600; color: #e2e8f0; flex: 1; }
+.analytics-close {
+ background: none; border: none; color: #94a3b8; font-size: 18px; cursor: pointer;
+}
+.analytics-close:hover { color: #e2e8f0; }
+.analytics-tabs { display: flex; gap: 4px; }
+.analytics-tab {
+ padding: 4px 10px; border: 1px solid #334155; border-radius: 4px;
+ background: transparent; color: #94a3b8; font-size: 11px; cursor: pointer;
+}
+.analytics-tab:hover { background: #334155; color: #e2e8f0; }
+.analytics-tab--active { background: #334155; color: #e2e8f0; }
+.analytics-content { padding: 16px; flex: 1; overflow-y: auto; }
+
.editor-field { display: flex; flex-direction: column; gap: 4px; }
.editor-label { font-size: 11px; color: #94a3b8; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
.editor-input {
@@ -469,7 +502,7 @@
/* Funnel overflow lip glow when overflowing */
.funnel-lip { transition: fill 0.3s, opacity 0.3s; }
-.funnel-lip--active { fill: #f59e0b; opacity: 0.8; }
+.funnel-lip--active { fill: #10b981; opacity: 0.8; }
/* Status badge in outcome inline edit */
.inline-status-badge { cursor: pointer; transition: opacity 0.15s; }
@@ -485,13 +518,14 @@
.flows-flows__grid { grid-template-columns: 1fr; }
.flows-features__grid { grid-template-columns: 1fr; }
.flows-cards { grid-template-columns: 1fr; }
- .flows-tabs { flex-wrap: wrap; }
- .flows-tab { min-height: 44px; min-width: 44px; display: flex; align-items: center; justify-content: center; }
.flows-canvas-container { height: 50vh; min-height: 300px; }
+ .flows-canvas-container--fullpage { height: 100%; min-height: unset; }
.flows-canvas-toolbar { flex-wrap: wrap; gap: 3px; top: 6px; right: 6px; }
.flows-canvas-btn { padding: 6px 10px; font-size: 11px; min-height: 44px; min-width: 44px; }
.flows-editor-panel { width: 100%; }
+ .flows-analytics-panel { width: 100%; }
.flows-canvas-legend { font-size: 10px; gap: 8px; }
.flows-landing { padding: 16px 12px 48px; }
.flows-detail { padding: 12px 12px 48px; }
+ .flows-detail--fullpage { padding: 0; }
}
diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts
index 22c1822..4b3fbb4 100644
--- a/modules/rflows/components/folk-flows-app.ts
+++ b/modules/rflows/components/folk-flows-app.ts
@@ -38,7 +38,6 @@ interface Transaction {
}
type View = "landing" | "detail";
-type Tab = "diagram" | "table" | "river" | "transactions";
// ─── Auth helpers (reads EncryptID session from localStorage) ──
@@ -60,8 +59,9 @@ class FolkFlowsApp extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private view: View = "landing";
- private tab: Tab = "diagram";
private flowId = "";
+ private analyticsOpen = false;
+ private analyticsTab: "overview" | "transactions" = "overview";
private isDemo = false;
private flows: FlowSummary[] = [];
@@ -357,38 +357,16 @@ class FolkFlowsApp extends HTMLElement {
// ─── Detail view with tabs ─────────────────────────────
private renderDetail(): string {
- const backUrl = this.getApiBase()
- ? `${this.getApiBase()}/`
- : "/rflows/";
+ if (this.loading) {
+ return '
Loading...
';
+ }
return `
-
-
-
← Flows
-
${this.esc(this.flowName || "Flow Detail")}
- ${this.isDemo ? '
Demo' : ""}
-
-
-
-
-
-
-
-
-
-
- ${this.loading ? '
Loading...
' : this.renderTab()}
-
+
+ ${this.renderDiagramTab()}
`;
}
- private renderTab(): string {
- if (this.tab === "diagram") return this.renderDiagramTab();
- if (this.tab === "river") return this.renderRiverTab();
- if (this.tab === "transactions") return this.renderTransactionsTab();
- return this.renderTableTab();
- }
-
// ─── Table tab ────────────────────────────────────────
private renderTableTab(): string {
@@ -562,12 +540,21 @@ class FolkFlowsApp extends HTMLElement {
return '
No nodes to display.
';
}
+ const backUrl = this.getApiBase()
+ ? `${this.getApiBase()}/`
+ : "/rflows/";
+
const score = computeSystemSufficiency(this.nodes);
const scorePct = Math.round(score * 100);
const scoreColor = scorePct >= 90 ? "#fbbf24" : scorePct >= 60 ? "#10b981" : scorePct >= 30 ? "#f59e0b" : "#ef4444";
return `
-
+
+
+
← Flows
+
${this.esc(this.flowName || "Flow Detail")}
+ ${this.isDemo ? '
Demo' : ""}
+
${scorePct}%
@@ -581,6 +568,7 @@ class FolkFlowsApp extends HTMLElement {
+
+ ${this.renderAnalyticsPanel()}
- Source
- Funnel
- Overflow
- Spending
- Outcome
- Sufficient
+ Inflow
+ Spending
+ Overflow
+ Critical
+ Sustained
+ Thriving
@@ -893,6 +882,7 @@ class FolkFlowsApp extends HTMLElement {
else if (action === "add-outcome") this.addNode("outcome");
else if (action === "sim") this.toggleSimulation();
else if (action === "fit") this.fitView();
+ else if (action === "analytics") this.toggleAnalytics();
else if (action === "share") this.shareState();
else if (action === "zoom-in") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); }
else if (action === "zoom-out") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); }
@@ -989,6 +979,7 @@ class FolkFlowsApp extends HTMLElement {
if (e.key === "Escape") {
if (this.inlineEditNodeId) { this.exitInlineEdit(); return; }
if (this.wiringActive) { this.cancelWiring(); return; }
+ if (this.analyticsOpen) { this.toggleAnalytics(); return; }
this.closeModal();
this.closeEditor();
}
@@ -1081,19 +1072,14 @@ class FolkFlowsApp extends HTMLElement {
const d = n.data as FunnelNodeData;
const s = this.getNodeSize(n);
const x = n.position.x, y = n.position.y, w = s.w, h = s.h;
- const sufficiency = computeSufficiencyState(d);
- const isSufficient = sufficiency === "sufficient" || sufficiency === "abundant";
- const isAbundant = sufficiency === "abundant";
const threshold = d.sufficientThreshold ?? d.maxThreshold;
const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1));
- const borderColor = d.currentValue > d.maxThreshold ? "#f59e0b"
- : d.currentValue < d.minThreshold ? "#ef4444"
- : isSufficient ? "#fbbf24" : "#0ea5e9";
+ const isOverflow = d.currentValue > d.maxThreshold;
+ const isCritical = d.currentValue < d.minThreshold;
+ const borderColor = isCritical ? "#ef4444" : isOverflow ? "#10b981" : "#f59e0b";
const fillColor = borderColor;
- const statusLabel = sufficiency === "abundant" ? "Abundant"
- : sufficiency === "sufficient" ? "Sufficient"
- : d.currentValue < d.minThreshold ? "Critical" : "Seeking";
+ const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sustained";
// Funnel shape parameters
const r = 10; // corner radius
@@ -1154,13 +1140,14 @@ class FolkFlowsApp extends HTMLElement {
const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : "";
const satBarBorder = satOverflow ? `stroke="#fbbf24" stroke-width="1"` : "";
- const glowClass = isSufficient ? " node-glow" : "";
+ const glowStyle = isOverflow ? "filter: drop-shadow(0 0 6px rgba(16,185,129,0.5))"
+ : !isCritical ? "filter: drop-shadow(0 0 6px rgba(245,158,11,0.4))" : "";
- return `
+ return `
- ${isSufficient ? `` : ""}
+ ${isOverflow ? `` : ""}
@@ -1168,10 +1155,10 @@ class FolkFlowsApp extends HTMLElement {
-
-
+
+
${this.esc(d.label)}
- ${statusLabel}
+ ${statusLabel}
${satLabel}
@@ -1265,7 +1252,7 @@ class FolkFlowsApp extends HTMLElement {
const flowAmount = d.flowRate * (alloc.percentage / 100);
edges.push({
fromNode: n, toNode: target, fromPort: "outflow",
- color: alloc.color || "#10b981", flowAmount,
+ color: "#10b981", flowAmount,
pct: alloc.percentage, dashed: false,
fromId: n.id, toId: alloc.targetId, edgeType: "source",
});
@@ -1283,7 +1270,7 @@ class FolkFlowsApp extends HTMLElement {
edges.push({
fromNode: n, toNode: target, fromPort: "overflow",
fromSide: side,
- color: alloc.color || "#f59e0b", flowAmount,
+ color: "#6ee7b7", flowAmount,
pct: alloc.percentage, dashed: true,
fromId: n.id, toId: alloc.targetId, edgeType: "overflow",
});
@@ -1300,7 +1287,7 @@ class FolkFlowsApp extends HTMLElement {
const flowAmount = drain * (alloc.percentage / 100);
edges.push({
fromNode: n, toNode: target, fromPort: "spending",
- color: alloc.color || "#8b5cf6", flowAmount,
+ color: "#34d399", flowAmount,
pct: alloc.percentage, dashed: false,
fromId: n.id, toId: alloc.targetId, edgeType: "spending",
});
@@ -1317,7 +1304,7 @@ class FolkFlowsApp extends HTMLElement {
const flowAmount = excess * (alloc.percentage / 100);
edges.push({
fromNode: n, toNode: target, fromPort: "overflow",
- color: alloc.color || "#f59e0b", flowAmount,
+ color: "#6ee7b7", flowAmount,
pct: alloc.percentage, dashed: true,
fromId: n.id, toId: alloc.targetId, edgeType: "overflow",
});
@@ -1446,11 +1433,9 @@ class FolkFlowsApp extends HTMLElement {
if (n.type === "source") return "#10b981";
if (n.type === "funnel") {
const d = n.data as FunnelNodeData;
- const suf = computeSufficiencyState(d);
- const isSuf = suf === "sufficient" || suf === "abundant";
- return d.currentValue > d.maxThreshold ? "#f59e0b"
- : d.currentValue < d.minThreshold ? "#ef4444"
- : isSuf ? "#fbbf24" : "#0ea5e9";
+ return d.currentValue < d.minThreshold ? "#ef4444"
+ : d.currentValue > d.maxThreshold ? "#10b981"
+ : "#f59e0b";
}
const d = n.data as OutcomeNodeData;
return d.status === "completed" ? "#10b981" : d.status === "blocked" ? "#ef4444" : d.status === "in-progress" ? "#3b82f6" : "#64748b";
@@ -2723,28 +2708,62 @@ class FolkFlowsApp extends HTMLElement {
}
}
- // ─── River tab ────────────────────────────────────────
+ // ─── Analytics popout panel ──────────────────────────
- private renderRiverTab(): string {
- return ``;
+ private renderAnalyticsPanel(): string {
+ return `
+
+
+
+ ${this.analyticsTab === "overview" ? this.renderTableTab() : this.renderTransactionsTab()}
+
+
`;
}
- private mountRiver() {
- const mount = this.shadow.getElementById("river-mount");
- if (!mount) return;
+ private toggleAnalytics() {
+ this.analyticsOpen = !this.analyticsOpen;
+ if (this.analyticsOpen && this.analyticsTab === "transactions" && !this.txLoaded) {
+ this.loadTransactions();
+ }
+ const panel = this.shadow.getElementById("analytics-panel");
+ if (panel) {
+ panel.classList.toggle("open", this.analyticsOpen);
+ }
+ const btn = this.shadow.querySelector('[data-canvas-action="analytics"]');
+ if (btn) btn.classList.toggle("flows-canvas-btn--active", this.analyticsOpen);
+ }
- // Check if already mounted
- if (mount.querySelector("folk-flow-river")) return;
+ private attachAnalyticsListeners() {
+ const closeBtn = this.shadow.querySelector("[data-analytics-close]");
+ closeBtn?.addEventListener("click", () => this.toggleAnalytics());
- const river = document.createElement("folk-flow-river") as any;
- river.setAttribute("simulate", "true");
- mount.appendChild(river);
-
- // Pass nodes after the element is connected
- requestAnimationFrame(() => {
- if (typeof river.setNodes === "function") {
- river.setNodes(this.nodes.map((n) => ({ ...n, data: { ...n.data } })));
- }
+ this.shadow.querySelectorAll("[data-analytics-tab]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const tab = (el as HTMLElement).dataset.analyticsTab as "overview" | "transactions";
+ if (tab === this.analyticsTab) return;
+ this.analyticsTab = tab;
+ if (tab === "transactions" && !this.txLoaded) {
+ this.loadTransactions();
+ return;
+ }
+ const panel = this.shadow.getElementById("analytics-panel");
+ if (panel) {
+ const content = panel.querySelector(".analytics-content");
+ if (content) {
+ content.innerHTML = this.analyticsTab === "overview" ? this.renderTableTab() : this.renderTransactionsTab();
+ }
+ panel.querySelectorAll(".analytics-tab").forEach((t) => {
+ t.classList.toggle("analytics-tab--active", (t as HTMLElement).dataset.analyticsTab === tab);
+ });
+ }
+ });
});
}
@@ -2808,30 +2827,10 @@ class FolkFlowsApp extends HTMLElement {
// ─── Event listeners ──────────────────────────────────
private attachListeners() {
- // Tab switching
- this.shadow.querySelectorAll("[data-tab]").forEach((el) => {
- el.addEventListener("click", () => {
- const newTab = (el as HTMLElement).dataset.tab as Tab;
- if (newTab === this.tab) return;
- // Cleanup old canvas state
- if (this.tab === "diagram") this.cleanupCanvas();
- this.tab = newTab;
- this.render();
-
- if (newTab === "transactions" && !this.txLoaded) {
- this.loadTransactions();
- }
- });
- });
-
- // Mount river component when river tab is active
- if (this.tab === "river" && this.nodes.length > 0) {
- this.mountRiver();
- }
-
- // Initialize interactive canvas when diagram tab is active
- if (this.tab === "diagram" && this.nodes.length > 0) {
+ // Initialize interactive canvas when detail view is active
+ if (this.view === "detail" && this.nodes.length > 0) {
this.initCanvas();
+ this.attachAnalyticsListeners();
}
// Create flow button (landing page, auth-gated)