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 {
+
@@ -591,13 +579,14 @@ 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 ` +
+
+ Analytics +
+ + +
+ +
+
+ ${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)