From a90e339323f6e87930db4e789cbe3264610e0700 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 4 Mar 2026 18:03:09 -0800 Subject: [PATCH] feat: inline config panel for rFlows elements with tabbed Config/Analytics/Alloc Double-clicking a flow element now opens a rich tabbed panel below the node: - Config tab: form inputs (label, thresholds with range sliders, source type, status) - Analytics tab: live-updating fill bars, conic-gradient outflow/overflow donut, stats - Allocations tab: read-only allocation breakdowns with colored dots - Simulation overlay preserved during ticks, analytics accumulate per-node metrics Co-Authored-By: Claude Opus 4.6 --- ...rename-mobile-touch-support-CSS-alignment.md | 49 + ...oring-full-page-canvas-analytics-popout.md | 48 + modules/rflows/components/flows.css | 316 +++++- modules/rflows/components/folk-flows-app.ts | 926 ++++++++++++------ modules/rflows/lib/presets.ts | 20 +- modules/rflows/lib/simulation.ts | 13 +- modules/rflows/lib/types.ts | 14 +- modules/rsplat/components/splat.css | 62 +- 8 files changed, 1140 insertions(+), 308 deletions(-) create mode 100644 backlog/tasks/task-102 - rFunds-→-rFlows-Full-module-rename-mobile-touch-support-CSS-alignment.md create mode 100644 backlog/tasks/task-103 - rFlows-Green-flows-funnel-coloring-full-page-canvas-analytics-popout.md diff --git a/backlog/tasks/task-102 - rFunds-→-rFlows-Full-module-rename-mobile-touch-support-CSS-alignment.md b/backlog/tasks/task-102 - rFunds-→-rFlows-Full-module-rename-mobile-touch-support-CSS-alignment.md new file mode 100644 index 0000000..cb93838 --- /dev/null +++ b/backlog/tasks/task-102 - rFunds-→-rFlows-Full-module-rename-mobile-touch-support-CSS-alignment.md @@ -0,0 +1,49 @@ +--- +id: TASK-102 +title: 'rFunds → rFlows: Full module rename, mobile touch support & CSS alignment' +status: Done +assignee: [] +created_date: '2026-03-04 03:19' +labels: + - rflows + - refactor + - mobile + - css +dependencies: [] +priority: medium +--- + +## Description + + +Complete rename of the rfunds module to rflows across the entire rspace-online codebase, plus mobile touch support and CSS formatting alignment. + +**Rename scope:** Directory, files, classes, custom elements, CSS classes, schemas, module registration, cross-codebase references (32+ files), Vite build config, Docker/Traefik configs, domain references (rfunds.online → rflows.online). + +**Mobile touch:** Two-finger pinch-to-zoom and pan on the flow diagram SVG canvas, following the canvas.html gesture pattern. Includes center-point zoom, drag threshold, and touch-action: none. + +**CSS alignment:** Renamed .funds-* → .flows-* class prefixes, aligned with rSpace dark theme conventions (slate palette, 8px border-radius, system-ui font, thin scrollbar, 44px mobile tap targets). + + +## Acceptance Criteria + +- [ ] #1 All rfunds references renamed to rflows across 32+ files +- [ ] #2 Vite build produces dist/modules/rflows/ with folk-flows-app.js, folk-flow-river.js, flows.css +- [ ] #3 Module loads at /{space}/rflows with correct shell and module switcher +- [ ] #4 Canvas embed button shows rFlows and uses moduleId rflows +- [ ] #5 Two-finger pinch-to-zoom on flow diagram SVG works with center-point zoom +- [ ] #6 Two-finger pan on flow diagram works +- [ ] #7 Single-touch node drag works with 5px threshold +- [ ] #8 CSS uses .flows-* prefix, 8px border-radius, system-ui font, thin scrollbar +- [ ] #9 Mobile toolbar collapses at 768px with 44px tap targets +- [ ] #10 Docker/Traefik configs updated for rflows.online domain +- [ ] #11 EncryptID CORS updated for rflows.online + + +## Final Summary + + +Completed full rfunds → rflows rename across the entire rspace-online codebase (32+ files). Added mobile two-finger pinch-to-zoom and pan to the flow diagram SVG canvas following canvas.html's gesture pattern. Aligned CSS with rSpace dark theme conventions. Build verified — dist/modules/rflows/ outputs correctly. Deployed to production successfully. + +Commit: a6008a4 refactor: complete rfunds → rflows rename across configs and references + diff --git a/backlog/tasks/task-103 - rFlows-Green-flows-funnel-coloring-full-page-canvas-analytics-popout.md b/backlog/tasks/task-103 - rFlows-Green-flows-funnel-coloring-full-page-canvas-analytics-popout.md new file mode 100644 index 0000000..9d53a90 --- /dev/null +++ b/backlog/tasks/task-103 - rFlows-Green-flows-funnel-coloring-full-page-canvas-analytics-popout.md @@ -0,0 +1,48 @@ +--- +id: TASK-103 +title: 'rFlows: Green flows, funnel coloring, full-page canvas, analytics popout' +status: Done +assignee: [] +created_date: '2026-03-04 05:06' +labels: + - rFlows + - UX + - frontend +dependencies: [] +references: + - modules/rflows/components/folk-flows-app.ts + - modules/rflows/components/flows.css + - modules/rflows/lib/presets.ts +priority: medium +--- + +## Description + + +Overhaul the rFlows detail view UX: + +1. **All flow edges → green shades** — Source inflow (#10b981), spending (#34d399), overflow (#6ee7b7). Ignores alloc.color for consistent visual language. +2. **Funnel 3-state coloring** — Critical (red, below min), Sustained (amber, between thresholds), Overflow (green, above max). Replaces previous 4-way sufficient/abundant/seeking/critical logic. Glow matches state color. +3. **Full-page canvas** — Removed tab system (Diagram/River/Table/Transactions). Canvas fills 100vh. Nav overlay (back link + title) floats inside canvas container. +4. **Analytics popout** — Left-side slide-in panel with Overview + Transactions sub-tabs. Toggled via toolbar button or Escape key. Reuses existing table/transaction renderers. +5. **Removed river tab** — Deleted renderRiverTab() and mountRiver(). folk-flow-river.ts kept on disk. +6. **Updated legend** — Edge colors as squares, funnel states as circles (Critical/Sustained/Thriving). + + +## Acceptance Criteria + +- [ ] #1 Canvas fills entire viewport (no tab bar, no max-width constraint) +- [ ] #2 All flow edges are shades of green +- [ ] #3 Funnels: red when critical, amber when sustained, green when overflow +- [ ] #4 Analytics button in toolbar opens left-side panel with overview + transactions +- [ ] #5 Back button in nav overlay returns to landing page +- [ ] #6 Simulation and wiring still work +- [ ] #7 Editor panel (right side) still opens for node editing +- [ ] #8 Escape closes analytics panel + + +## Final Summary + + +Committed 676aaa7 on dev, merged to main. Two files changed: folk-flows-app.ts (199 line diff — removed tab system, added analytics panel, updated edge/funnel color logic) and flows.css (68 line diff — fullpage layout, nav overlay, analytics panel styles). TypeScript compiles clean. + diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index 3c11a15..1780dfd 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -1,5 +1,75 @@ /* ── Flows module theme ───────────────────────────────── */ +/* ── rFlows color tokens (dark defaults) ─────────────── */ +:root { + /* Source node */ + --rflows-source-bg: #064e3b; + --rflows-source-border: #10b981; + --rflows-source-rate: #6ee7b7; + + /* Edge colors */ + --rflows-edge-inflow: #10b981; + --rflows-edge-spending: #34d399; + --rflows-edge-overflow: #6ee7b7; + + /* Funnel zones */ + --rflows-zone-drain: #ef4444; + --rflows-zone-drain-opacity: 0.08; + --rflows-zone-healthy: #0ea5e9; + --rflows-zone-healthy-opacity: 0.06; + --rflows-zone-overflow: #f59e0b; + --rflows-zone-overflow-opacity: 0.06; + --rflows-fill-opacity: 0.25; + + /* Funnel labels */ + --rflows-label-inflow: #10b981; + --rflows-label-spending: #34d399; + --rflows-label-overflow: #6ee7b7; + + /* Status colors */ + --rflows-status-critical: #ef4444; + --rflows-status-sustained: #f59e0b; + --rflows-status-overflow: #10b981; + --rflows-status-thriving: #10b981; + --rflows-sat-bar: #10b981; + --rflows-sat-border: #fbbf24; + + /* Outcome / progress */ + --rflows-status-completed: #10b981; + --rflows-status-blocked: #ef4444; + --rflows-status-inprogress: #3b82f6; + --rflows-status-notstarted: #64748b; + --rflows-phase-unlocked: #10b981; + + /* Score badge */ + --rflows-score-gold: #fbbf24; + --rflows-score-green: #10b981; + --rflows-score-amber: #f59e0b; + --rflows-score-red: #ef4444; + + /* Card value */ + --rflows-card-value: #0ea5e9; + + /* Selection */ + --rflows-selected: #6366f1; + + /* Inline edit buttons */ + --rflows-btn-done: #10b981; + --rflows-btn-delete: #ef4444; + --rflows-btn-fund: #6366f1; + --rflows-btn-save: #10b981; + + /* Sufficiency tooltip highlight */ + --rflows-sufficiency-highlight: #fbbf24; + + /* Edge drag handle */ + --rflows-drag-handle-fill: #475569; + --rflows-drag-handle-stroke: #94a3b8; + + /* Modal border accent */ + --rflows-modal-border: #334155; +} + /* ── Base ────────────────────────────────────────────── */ .flows-landing, .flows-detail { font-family: system-ui, -apple-system, sans-serif; @@ -54,7 +124,7 @@ } .flows-flow-card:hover { border-color: var(--rs-primary-hover); transform: translateY(-1px); } .flows-flow-card__name { font-size: 15px; font-weight: 600; color: var(--rs-text-primary); margin-bottom: 4px; } -.flows-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: var(--rflows-card-value, #0ea5e9); margin-bottom: 4px; } .flows-flow-card__meta { font-size: 12px; color: var(--rs-text-muted); } /* About / how-it-works section */ @@ -396,8 +466,8 @@ /* Drag handle on edge midpoint */ .edge-drag-handle { - fill: #475569; - stroke: #94a3b8; + fill: var(--rflows-drag-handle-fill, #475569); + stroke: var(--rflows-drag-handle-stroke, #94a3b8); stroke-width: 1.5; cursor: grab; transition: fill 0.15s; @@ -534,14 +604,174 @@ /* Inline edit overlay container */ .inline-edit-overlay { pointer-events: all; } -/* Funnel overflow lip glow when overflowing */ -.funnel-lip { transition: fill 0.3s, opacity 0.3s; } -.funnel-lip--active { fill: #10b981; opacity: 0.8; } +/* Funnel overflow pipe (vessel metaphor) */ +.funnel-pipe { transition: fill 0.3s, height 0.3s, y 0.3s; } +.funnel-pipe--active { fill: #10b981; } + +/* Threshold lines inside tank */ +.threshold-line { pointer-events: none; } + +/* Basin fill animation */ +.basin-fill-rect { transition: y 120ms ease-out, height 120ms ease-out; } /* Status badge in outcome inline edit */ .inline-status-badge { cursor: pointer; transition: opacity 0.15s; } .inline-status-badge:hover { opacity: 0.8; } +/* ── Inline config panel (ICP) ─────────────────────── */ +.inline-config-panel { + display: flex; flex-direction: column; + background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); + border-radius: 10px; box-shadow: 0 4px 24px rgba(0,0,0,0.35); + overflow: hidden; font-family: system-ui, -apple-system, sans-serif; + color: var(--rs-text-primary); font-size: 12px; +} +.icp-tabs { + display: flex; border-bottom: 1px solid var(--rs-border-strong); + background: var(--rs-bg-surface-sunken); +} +.icp-tab { + flex: 1; padding: 6px 8px; text-align: center; + font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; + color: var(--rs-text-muted); cursor: pointer; border: none; background: none; + border-bottom: 2px solid transparent; transition: color 0.15s, border-color 0.15s; +} +.icp-tab:hover { color: var(--rs-text-secondary); } +.icp-tab--active { + color: var(--rs-primary); border-bottom-color: var(--rs-primary); +} +.icp-body { + flex: 1; overflow-y: auto; padding: 8px 10px; + max-height: 180px; min-height: 60px; +} +.icp-body::-webkit-scrollbar { width: 4px; } +.icp-body::-webkit-scrollbar-thumb { background: var(--rs-border-strong); border-radius: 2px; } +.icp-toolbar { + display: flex; gap: 4px; justify-content: center; + padding: 6px 10px; border-top: 1px solid var(--rs-border-strong); + background: var(--rs-bg-surface-sunken); +} +.icp-toolbar button { + padding: 4px 10px; border-radius: 5px; border: none; + font-size: 10px; cursor: pointer; font-weight: 600; + font-family: system-ui, -apple-system, sans-serif; + transition: opacity 0.15s; +} +.icp-toolbar button:hover { opacity: 0.85; } + +/* Form fields */ +.icp-field { margin-bottom: 6px; } +.icp-label { + display: block; font-size: 10px; font-weight: 600; + color: var(--rs-text-muted); text-transform: uppercase; letter-spacing: 0.03em; + margin-bottom: 2px; +} +.icp-input, .icp-select { + width: 100%; padding: 4px 6px; border-radius: 4px; + border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface-sunken); + color: var(--rs-text-primary); font-size: 12px; + font-family: system-ui, -apple-system, sans-serif; + outline: none; box-sizing: border-box; +} +.icp-input:focus, .icp-select:focus { border-color: var(--rs-primary); } + +/* Range sliders */ +.icp-range-group { + display: flex; align-items: center; gap: 6px; margin-bottom: 6px; +} +.icp-range-label { + font-size: 10px; font-weight: 600; color: var(--rs-text-muted); + min-width: 28px; text-transform: uppercase; +} +.icp-range { + flex: 1; height: 4px; cursor: pointer; + accent-color: var(--rs-primary); + -webkit-appearance: none; appearance: none; + background: var(--rs-border-strong); border-radius: 2px; +} +.icp-range::-webkit-slider-thumb { + -webkit-appearance: none; width: 12px; height: 12px; + border-radius: 50%; background: var(--rs-primary); cursor: pointer; +} +.icp-range-value { + font-size: 11px; font-weight: 600; color: var(--rs-text-primary); + min-width: 40px; text-align: right; +} + +/* Analytics bars */ +.icp-analytics-row { + margin-bottom: 8px; +} +.icp-analytics-label { + display: flex; justify-content: space-between; font-size: 10px; + color: var(--rs-text-muted); margin-bottom: 2px; +} +.icp-analytics-bar { + height: 6px; background: var(--rs-border-strong); border-radius: 3px; + overflow: hidden; +} +.icp-analytics-fill { + height: 100%; border-radius: 3px; transition: width 120ms ease-out; +} + +/* Conic-gradient donut for proportions */ +.icp-proportion { + display: flex; align-items: center; gap: 10px; margin-bottom: 8px; +} +.icp-proportion-ring { + width: 48px; height: 48px; border-radius: 50%; flex-shrink: 0; +} +.icp-proportion-legend { + display: flex; flex-direction: column; gap: 3px; font-size: 10px; +} +.icp-proportion-item { + display: flex; align-items: center; gap: 4px; +} +.icp-proportion-dot { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; +} + +/* Stat pairs */ +.icp-stat-row { + display: flex; justify-content: space-between; font-size: 11px; + padding: 2px 0; color: var(--rs-text-secondary); +} +.icp-stat-value { font-weight: 600; color: var(--rs-text-primary); } + +/* Allocation rows */ +.icp-alloc-row { + display: flex; align-items: center; gap: 6px; padding: 3px 0; + font-size: 11px; color: var(--rs-text-secondary); +} +.icp-alloc-dot { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; +} + +/* Fund Now button */ +.icp-fund-btn { + width: 100%; padding: 6px; margin-top: 6px; + background: var(--rflows-btn-fund); color: white; border: none; + border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 11px; + font-family: system-ui, -apple-system, sans-serif; + transition: opacity 0.15s; +} +.icp-fund-btn:hover { opacity: 0.85; } + +/* Sufficiency state label */ +.icp-suf-badge { + display: inline-block; padding: 2px 8px; border-radius: 4px; + font-size: 10px; font-weight: 600; text-transform: uppercase; +} +.icp-suf-badge--seeking { background: rgba(59,130,246,0.15); color: #3b82f6; } +.icp-suf-badge--sufficient { background: rgba(16,185,129,0.15); color: #10b981; } +.icp-suf-badge--abundant { background: rgba(245,158,11,0.15); color: #f59e0b; } + +/* Empty state */ +.icp-empty { + text-align: center; color: var(--rs-text-muted); + padding: 12px 8px; font-size: 11px; font-style: italic; +} + /* Side port arrows */ .port-group[data-port-side="left"] .port-arrow { /* horizontal arrow left handled inline */ } .port-group[data-port-side="right"] .port-arrow { /* horizontal arrow right handled inline */ } @@ -594,6 +824,49 @@ --rflows-outcome-hover-bg: #fce7f3; --rflows-danger-text: #dc2626; --rflows-danger-hover-bg: #fee2e2; + + /* Source node */ + --rflows-source-bg: #d1fae5; + --rflows-source-border: #059669; + --rflows-source-rate: #047857; + + /* Edge colors */ + --rflows-edge-inflow: #059669; + --rflows-edge-spending: #047857; + --rflows-edge-overflow: #059669; + + /* Funnel zones */ + --rflows-zone-drain-opacity: 0.15; + --rflows-zone-healthy-opacity: 0.12; + --rflows-zone-overflow-opacity: 0.12; + --rflows-fill-opacity: 0.35; + + /* Funnel labels */ + --rflows-label-inflow: #047857; + --rflows-label-spending: #047857; + --rflows-label-overflow: #059669; + + /* Status colors (darken for light bg) */ + --rflows-status-overflow: #059669; + --rflows-status-thriving: #059669; + --rflows-sat-bar: #059669; + + /* Outcome */ + --rflows-status-completed: #059669; + --rflows-phase-unlocked: #059669; + + /* Card value */ + --rflows-card-value: #0369a1; + + /* Score badge */ + --rflows-score-green: #059669; + + /* Edge drag handle */ + --rflows-drag-handle-fill: #94a3b8; + --rflows-drag-handle-stroke: #64748b; + + /* Modal border accent */ + --rflows-modal-border: #e2e8f0; } @media (prefers-color-scheme: light) { :root:not([data-theme]) { @@ -605,6 +878,37 @@ --rflows-outcome-hover-bg: #fce7f3; --rflows-danger-text: #dc2626; --rflows-danger-hover-bg: #fee2e2; + + --rflows-source-bg: #d1fae5; + --rflows-source-border: #059669; + --rflows-source-rate: #047857; + + --rflows-edge-inflow: #059669; + --rflows-edge-spending: #047857; + --rflows-edge-overflow: #059669; + + --rflows-zone-drain-opacity: 0.15; + --rflows-zone-healthy-opacity: 0.12; + --rflows-zone-overflow-opacity: 0.12; + --rflows-fill-opacity: 0.35; + + --rflows-label-inflow: #047857; + --rflows-label-spending: #047857; + --rflows-label-overflow: #059669; + + --rflows-status-overflow: #059669; + --rflows-status-thriving: #059669; + --rflows-sat-bar: #059669; + + --rflows-status-completed: #059669; + --rflows-phase-unlocked: #059669; + + --rflows-card-value: #0369a1; + --rflows-score-green: #059669; + + --rflows-drag-handle-fill: #94a3b8; + --rflows-drag-handle-stroke: #64748b; + --rflows-modal-border: #e2e8f0; } } diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 54210d3..3f025b0 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -12,7 +12,7 @@ */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types"; -import { PORT_DEFS } from "../lib/types"; +import { PORT_DEFS, deriveThresholds } from "../lib/types"; import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation"; import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets"; import { mapFlowToNodes } from "../lib/map-flow"; @@ -39,6 +39,17 @@ interface Transaction { type View = "landing" | "detail"; +interface NodeAnalyticsStats { + totalInflow: number; + totalOutflow: number; + totalOverflow: number; + avgFillLevel: number; + peakValue: number; + outcomesAchieved: number; + tickCount: number; + fillLevelSum: number; // running sum for average +} + // ─── Auth helpers (reads EncryptID session from localStorage) ── function getSession(): { accessToken: string; claims: { sub: string; username?: string } } | null { @@ -100,11 +111,13 @@ class FolkFlowsApp extends HTMLElement { private draggingEdgeKey: string | null = null; private edgeDragPointerId: number | null = null; - // Inline edit state + // Inline config panel state private inlineEditNodeId: string | null = null; + private inlineConfigTab: "config" | "analytics" | "allocations" = "config"; private inlineEditDragThreshold: string | null = null; private inlineEditDragStartY = 0; private inlineEditDragStartValue = 0; + private nodeAnalytics: Map = new Map(); // Wiring state private wiringActive = false; @@ -505,10 +518,10 @@ class FolkFlowsApp extends HTMLElement { const fillPct = data.fundingTarget > 0 ? Math.min(100, (data.fundingReceived / data.fundingTarget) * 100) : 0; - const statusColor = data.status === "completed" ? "#10b981" - : data.status === "blocked" ? "#ef4444" - : data.status === "in-progress" ? "#3b82f6" - : "#64748b"; + const statusColor = data.status === "completed" ? "var(--rflows-status-completed)" + : data.status === "blocked" ? "var(--rflows-status-blocked)" + : data.status === "in-progress" ? "var(--rflows-status-inprogress)" + : "var(--rflows-status-notstarted)"; return `
@@ -553,7 +566,7 @@ class FolkFlowsApp extends HTMLElement { const score = computeSystemSufficiency(this.nodes); const scorePct = Math.round(score * 100); - const scoreColor = scorePct >= 90 ? "#fbbf24" : scorePct >= 60 ? "#10b981" : scorePct >= 30 ? "#f59e0b" : "#ef4444"; + const scoreColor = scorePct >= 90 ? "var(--rflows-score-gold)" : scorePct >= 60 ? "var(--rflows-score-green)" : scorePct >= 30 ? "var(--rflows-score-amber)" : "var(--rflows-score-red)"; return `
@@ -588,12 +601,12 @@ class FolkFlowsApp extends HTMLElement {
${this.renderAnalyticsPanel()}
- Inflow - Spending - Overflow - Critical - Sustained - Thriving + Inflow + Spending + Overflow + Critical + Sustained + Thriving
@@ -665,15 +678,15 @@ class FolkFlowsApp extends HTMLElement { } private getNodeSize(n: FlowNode): { w: number; h: number } { - if (n.type === "source") return { w: 200, h: 60 }; + if (n.type === "source") return { w: 200, h: 70 }; if (n.type === "funnel") { const d = n.data as FunnelNodeData; - const baseW = 200, baseH = 160; - // Scale: $1k/mo = 1x, $10k/mo = ~1.3x, $100k/mo = ~1.6x (logarithmic) - const scale = 1 + Math.log10(Math.max(1, d.inflowRate / 1000)) * 0.3; + const baseW = 200, baseH = 180; + const scaleRef = d.desiredOutflow || d.inflowRate; + const scale = 1 + Math.log10(Math.max(1, scaleRef / 1000)) * 0.3; return { w: Math.round(baseW * scale), h: Math.round(baseH * scale) }; } - return { w: 200, h: 100 }; // outcome + return { w: 200, h: 110 }; // outcome (basin) } // ─── Canvas event wiring ────────────────────────────── @@ -1176,15 +1189,17 @@ class FolkFlowsApp extends HTMLElement { private renderSourceNodeSvg(n: FlowNode, selected: boolean): string { const d = n.data as SourceNodeData; - const x = n.position.x, y = n.position.y, w = 200, h = 60; + const x = n.position.x, y = n.position.y, w = 200, h = 70; const icons: Record = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" }; const icon = icons[d.sourceType] || "\u{1F4B0}"; + const stubW = 24, stubH = 20; return ` - + + ${icon} ${this.esc(d.label)} - $${d.flowRate.toLocaleString()}/mo - ${this.renderAllocBar(d.targetAllocations, w, h - 6)} + $${d.flowRate.toLocaleString()}/mo + ${this.renderAllocBar(d.targetAllocations, w, 48)} ${this.renderPortsSvg(n)} `; } @@ -1198,47 +1213,57 @@ class FolkFlowsApp extends HTMLElement { const isOverflow = d.currentValue > d.maxThreshold; const isCritical = d.currentValue < d.minThreshold; - const borderColor = isCritical ? "#ef4444" : isOverflow ? "#10b981" : "#f59e0b"; - const fillColor = borderColor; + const borderColorVar = isCritical ? "var(--rflows-status-critical)" : isOverflow ? "var(--rflows-status-overflow)" : "var(--rflows-status-sustained)"; + const fillColor = borderColorVar; const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sustained"; - // Funnel shape parameters - const r = 10; // corner radius - const lipW = 14; // overflow lip extension - const lipH = Math.round(h * 0.08); // lip notch top offset - const lipNotch = 14; // lip notch height - const taperStart = 0.65; // body tapers at 65% down - const taperInset = 0.2; // bottom is 60% of top width + // Tank shape parameters + const r = 10; + const pipeW = 24; // overflow pipe extension from wall + const basePipeH = 20; // base pipe height + const pipeYFrac = 0.55; // pipe center at ~55% down + const taperStart = 0.75; // body tapers at 75% down + const taperInset = 0.2; const insetPx = Math.round(w * taperInset); const taperY = Math.round(h * taperStart); const clipId = `funnel-clip-${n.id}`; - // Funnel SVG path: wide top with lip notches, tapering to narrow bottom - const funnelPath = [ - `M ${r},0`, // top-left after corner - `L ${w - r},0`, // across top - `Q ${w},0 ${w},${r}`, // top-right corner - `L ${w},${lipH}`, // down to right lip - `L ${w + lipW},${lipH}`, // right lip extends - `L ${w + lipW},${lipH + lipNotch}`, // right lip bottom - `L ${w},${lipH + lipNotch}`, // back to body - `L ${w},${taperY}`, // down right side to taper - `Q ${w},${taperY + (h - taperY) * 0.3} ${w - insetPx},${h - r}`, // taper curve right - `Q ${w - insetPx},${h} ${w - insetPx - r},${h}`, // bottom-right corner - `L ${insetPx + r},${h}`, // across narrow bottom - `Q ${insetPx},${h} ${insetPx},${h - r}`, // bottom-left corner - `Q 0,${taperY + (h - taperY) * 0.3} 0,${taperY}`, // taper curve left - `L 0,${lipH + lipNotch}`, // up left side from taper - `L ${-lipW},${lipH + lipNotch}`, // left lip bottom - `L ${-lipW},${lipH}`, // left lip top - `L 0,${lipH}`, // back to body - `L 0,${r}`, // up to top-left - `Q 0,0 ${r},0`, // top-left corner + // Dynamic pipe sizing for overflow + let pipeH = basePipeH; + let pipeY = Math.round(h * pipeYFrac) - basePipeH / 2; + let excessRatio = 0; + if (isOverflow && d.maxCapacity > d.maxThreshold) { + excessRatio = Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold)); + pipeH = basePipeH + excessRatio * 16; + pipeY = Math.round(h * pipeYFrac) - basePipeH / 2 - excessRatio * 8; + } + + // Tank SVG path: flat-top wide body with pipe notches, tapering to drain at bottom + const tankPath = [ + `M ${r},0`, + `L ${w - r},0`, + `Q ${w},0 ${w},${r}`, + `L ${w},${pipeY}`, + `L ${w + pipeW},${pipeY}`, + `L ${w + pipeW},${pipeY + pipeH}`, + `L ${w},${pipeY + pipeH}`, + `L ${w},${taperY}`, + `Q ${w},${taperY + (h - taperY) * 0.3} ${w - insetPx},${h - r}`, + `Q ${w - insetPx},${h} ${w - insetPx - r},${h}`, + `L ${insetPx + r},${h}`, + `Q ${insetPx},${h} ${insetPx},${h - r}`, + `Q 0,${taperY + (h - taperY) * 0.3} 0,${taperY}`, + `L 0,${pipeY + pipeH}`, + `L ${-pipeW},${pipeY + pipeH}`, + `L ${-pipeW},${pipeY}`, + `L 0,${pipeY}`, + `L 0,${r}`, + `Q 0,0 ${r},0`, `Z`, ].join(" "); - // Interior regions (clipped to funnel shape) - const zoneTop = lipH + lipNotch + 4; + // Interior fill zones + const zoneTop = 28; const zoneBot = h - 4; const zoneH = zoneBot - zoneTop; const drainPct = d.minThreshold / (d.maxCapacity || 1); @@ -1252,14 +1277,30 @@ class FolkFlowsApp extends HTMLElement { const totalFillH = zoneH * fillPct; const fillY = zoneTop + zoneH - totalFillH; + // Threshold lines (always visible) + const minFrac = d.minThreshold / (d.maxCapacity || 1); + const sufFrac = (d.sufficientThreshold ?? d.maxThreshold) / (d.maxCapacity || 1); + const maxFrac = d.maxThreshold / (d.maxCapacity || 1); + const minLineY = zoneTop + zoneH * (1 - minFrac); + const sufLineY = zoneTop + zoneH * (1 - sufFrac); + const maxLineY = zoneTop + zoneH * (1 - maxFrac); + + const thresholdLines = ` + + Min + + Suf + + Overflow`; + // Inflow satisfaction bar - const satBarY = lipH + lipNotch + 22; + const satBarY = 40; const satBarW = w - 40; const satRatio = sat ? Math.min(sat.ratio, 1) : 0; const satOverflow = sat ? sat.ratio > 1 : false; const satFillW = satBarW * satRatio; const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : ""; - const satBarBorder = satOverflow ? `stroke="#fbbf24" stroke-width="1"` : ""; + const satBarBorder = satOverflow ? `stroke="var(--rflows-sat-border)" stroke-width="1"` : ""; 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))" : ""; @@ -1267,75 +1308,100 @@ class FolkFlowsApp extends HTMLElement { // Rate labels const inflowLabel = `\u2193 ${this.formatDollar(d.inflowRate)}/mo`; + const baseRate = d.desiredOutflow || d.inflowRate; let rateMultiplier: number; if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8; else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5; else rateMultiplier = 0.1; - const spendingRate = d.inflowRate * rateMultiplier; + const spendingRate = baseRate * rateMultiplier; const spendingLabel = `\u2193 ${this.formatDollar(spendingRate)}/mo`; const excess = Math.max(0, d.currentValue - d.maxThreshold); const overflowLabel = isOverflow ? this.formatDollar(excess) : ""; + return ` - + - ${isOverflow ? `` : ""} - + ${isOverflow ? `` : ""} + - - - - + + + + + ${thresholdLines} - - - ${inflowLabel} - ${this.esc(d.label)} - ${statusLabel} + + + ${inflowLabel} + ${this.esc(d.label)} + ${statusLabel} - + ${satLabel} $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} - - ${spendingLabel} - ${isOverflow ? `${overflowLabel} - ${overflowLabel}` : ""} + + ${spendingLabel} + ${isOverflow ? `${overflowLabel} + ${overflowLabel}` : ""} ${this.renderPortsSvg(n)} `; } private renderOutcomeNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string { const d = n.data as OutcomeNodeData; - const x = n.position.x, y = n.position.y, w = 200, h = 100; + const x = n.position.x, y = n.position.y, w = 200, h = 110; const fillPct = d.fundingTarget > 0 ? Math.min(1, d.fundingReceived / d.fundingTarget) : 0; - const statusColor = d.status === "completed" ? "#10b981" - : d.status === "blocked" ? "#ef4444" - : d.status === "in-progress" ? "#3b82f6" : "#64748b"; + const statusColorVar = d.status === "completed" ? "var(--rflows-status-completed)" + : d.status === "blocked" ? "var(--rflows-status-blocked)" + : d.status === "in-progress" ? "var(--rflows-status-inprogress)" : "var(--rflows-status-notstarted)"; + + // Basin shape: slightly flared walls (8px wider at top) + const flare = 8; + const clipId = `basin-clip-${n.id}`; + const basinPath = [ + `M ${-flare},0`, + `L ${w + flare},0`, + `Q ${w + flare},4 ${w + flare - 2},8`, + `L ${w},${h - 8}`, + `Q ${w},${h} ${w - 8},${h}`, + `L 8,${h}`, + `Q 0,${h} 0,${h - 8}`, + `L ${-flare + 2},8`, + `Q ${-flare},4 ${-flare},0`, + `Z`, + ].join(" "); + + // Fill level from bottom + const fillZoneTop = 30; + const fillZoneH = h - fillZoneTop - 4; + const fillH = fillZoneH * fillPct; + const fillY = fillZoneTop + fillZoneH - fillH; let phaseBars = ""; if (d.phases && d.phases.length > 0) { const phaseW = (w - 20) / d.phases.length; phaseBars = d.phases.map((p, i) => { const unlocked = d.fundingReceived >= p.fundingThreshold; - return ``; + return ``; }).join(""); - phaseBars += `${d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length}/${d.phases.length} phases`; + phaseBars += `${d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length}/${d.phases.length} phases`; } - // Enhanced progress bar (8px height, green funded portion + grey gap) - const barW = w - 20; - const barY = 34; - const barH = 8; const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`; return ` - - + + + + + + + + ${this.esc(d.label)} - - - ${Math.round(fillPct * 100)}% — ${dollarLabel} + ${Math.round(fillPct * 100)}% — ${dollarLabel} ${phaseBars} ${this.renderPortsSvg(n)} `; @@ -1389,7 +1455,7 @@ class FolkFlowsApp extends HTMLElement { const flowAmount = d.flowRate * (alloc.percentage / 100); edges.push({ fromNode: n, toNode: target, fromPort: "outflow", - color: "#10b981", flowAmount, + color: "var(--rflows-edge-inflow)", flowAmount, pct: alloc.percentage, dashed: false, fromId: n.id, toId: alloc.targetId, edgeType: "source", waypoint: alloc.waypoint, @@ -1408,7 +1474,7 @@ class FolkFlowsApp extends HTMLElement { edges.push({ fromNode: n, toNode: target, fromPort: "overflow", fromSide: side, - color: "#6ee7b7", flowAmount, + color: "var(--rflows-edge-overflow)", flowAmount, pct: alloc.percentage, dashed: true, fromId: n.id, toId: alloc.targetId, edgeType: "overflow", waypoint: alloc.waypoint, @@ -1426,7 +1492,7 @@ class FolkFlowsApp extends HTMLElement { const flowAmount = drain * (alloc.percentage / 100); edges.push({ fromNode: n, toNode: target, fromPort: "spending", - color: "#34d399", flowAmount, + color: "var(--rflows-edge-spending)", flowAmount, pct: alloc.percentage, dashed: false, fromId: n.id, toId: alloc.targetId, edgeType: "spending", waypoint: alloc.waypoint, @@ -1444,7 +1510,7 @@ class FolkFlowsApp extends HTMLElement { const flowAmount = excess * (alloc.percentage / 100); edges.push({ fromNode: n, toNode: target, fromPort: "overflow", - color: "#6ee7b7", flowAmount, + color: "var(--rflows-edge-overflow)", flowAmount, pct: alloc.percentage, dashed: true, fromId: n.id, toId: alloc.targetId, edgeType: "overflow", waypoint: alloc.waypoint, @@ -1522,10 +1588,10 @@ class FolkFlowsApp extends HTMLElement { if (ghost) { return ` ${hitPath} - + - ${label} + ${label} @@ -1548,13 +1614,13 @@ class FolkFlowsApp extends HTMLElement { const dragHandle = ``; return ` ${hitPath} - - - ${dashed ? `` : ""} + + + ${dashed ? `` : ""} ${dragHandle} - ${label} + ${label} @@ -1616,7 +1682,7 @@ class FolkFlowsApp extends HTMLElement { const bg = el.querySelector(".node-bg") as SVGElement | null; if (bg) { if (isSelected) { - bg.setAttribute("stroke", "#6366f1"); + bg.setAttribute("stroke", "var(--rflows-selected)"); bg.setAttribute("stroke-width", "3"); } else { // Restore original color @@ -1644,15 +1710,15 @@ class FolkFlowsApp extends HTMLElement { } private getNodeBorderColor(n: FlowNode): string { - if (n.type === "source") return "#10b981"; + if (n.type === "source") return "var(--rflows-source-border)"; if (n.type === "funnel") { const d = n.data as FunnelNodeData; - return d.currentValue < d.minThreshold ? "#ef4444" - : d.currentValue > d.maxThreshold ? "#10b981" - : "#f59e0b"; + return d.currentValue < d.minThreshold ? "var(--rflows-status-critical)" + : d.currentValue > d.maxThreshold ? "var(--rflows-status-overflow)" + : "var(--rflows-status-sustained)"; } const d = n.data as OutcomeNodeData; - return d.status === "completed" ? "#10b981" : d.status === "blocked" ? "#ef4444" : d.status === "in-progress" ? "#3b82f6" : "#64748b"; + return d.status === "completed" ? "var(--rflows-status-completed)" : d.status === "blocked" ? "var(--rflows-status-blocked)" : d.status === "in-progress" ? "var(--rflows-status-inprogress)" : "var(--rflows-status-notstarted)"; } // ─── Port rendering & wiring ───────────────────────── @@ -1675,6 +1741,21 @@ class FolkFlowsApp extends HTMLElement { def = this.getPortDefs(node.type).find((p) => p.kind === portKind); } if (!def) return { x: node.position.x + s.w / 2, y: node.position.y + s.h / 2 }; + + // Dynamic overflow port Y for funnels — match pipe position + if (node.type === "funnel" && portKind === "overflow" && def.side) { + const d = node.data as FunnelNodeData; + const h = s.h; + const basePipeH = 20; + let pipeY = Math.round(h * 0.55) - basePipeH / 2; + if (d.currentValue > d.maxThreshold && d.maxCapacity > d.maxThreshold) { + const er = Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold)); + pipeY = Math.round(h * 0.55) - basePipeH / 2 - er * 8; + } + const pipeMidY = pipeY + basePipeH / 2; + return { x: node.position.x + s.w * def.xFrac, y: node.position.y + pipeMidY }; + } + return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac }; } @@ -1696,18 +1777,18 @@ class FolkFlowsApp extends HTMLElement { if (p.side) { // Side port: horizontal arrow if (p.side === "left") { - arrow = ``; + arrow = ``; } else { - arrow = ``; + arrow = ``; } } else if (p.dir === "out") { - arrow = ``; + arrow = ``; } else { - arrow = ``; + arrow = ``; } return ` - + ${arrow} `; }).join(""); @@ -1950,134 +2031,346 @@ class FolkFlowsApp extends HTMLElement { if (panel) { panel.classList.remove("open"); panel.innerHTML = ""; } } - // ─── Inline edit mode ───────────────────────────────── + // ─── Inline config panel ───────────────────────────── private enterInlineEdit(nodeId: string) { - // Exit any previous inline edit if (this.inlineEditNodeId && this.inlineEditNodeId !== nodeId) { this.exitInlineEdit(); } this.inlineEditNodeId = nodeId; + this.inlineConfigTab = "config"; this.selectedNodeId = nodeId; this.updateSelectionHighlight(); const node = this.nodes.find((n) => n.id === nodeId); if (!node) return; - // Overlay the inline edit SVG elements on the node const nodeLayer = this.shadow.getElementById("node-layer"); const g = nodeLayer?.querySelector(`[data-node-id="${nodeId}"]`) as SVGGElement | null; if (!g) return; - // Remove any existing inline edit overlay g.querySelector(".inline-edit-overlay")?.remove(); const s = this.getNodeSize(node); const overlay = document.createElementNS("http://www.w3.org/2000/svg", "g"); overlay.classList.add("inline-edit-overlay"); + // For funnels, render threshold drag markers on the node body if (node.type === "funnel") { - this.renderFunnelInlineEdit(overlay, node, s); - } else if (node.type === "source") { - this.renderSourceInlineEdit(overlay, node, s); - } else { - this.renderOutcomeInlineEdit(overlay, node, s); + this.renderFunnelThresholdMarkers(overlay, node, s); } - // Toolbar: Done | Delete | ... - const toolbarY = s.h + 8; + // Panel positioned below the node + const panelW = Math.max(280, s.w); + const panelH = 260; + const panelX = (s.w - panelW) / 2; + const panelY = s.h + 8; + overlay.innerHTML += ` - -
- - - + +
+
+ + + +
+
${this.renderInlineConfigContent(node)}
+
+ + + +
`; g.appendChild(overlay); - this.attachInlineEditListeners(g, node); + this.attachInlineConfigListeners(g, node); } - private renderFunnelInlineEdit(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) { + private renderInlineConfigContent(node: FlowNode): string { + if (this.inlineConfigTab === "config") return this.renderInlineConfigTab(node); + if (this.inlineConfigTab === "analytics") return this.renderInlineAnalyticsTab(node); + return this.renderInlineAllocTab(node); + } + + // ── Config tab renderers ── + + private renderInlineConfigTab(node: FlowNode): string { + if (node.type === "source") return this.renderSourceConfigTab(node); + if (node.type === "funnel") return this.renderFunnelConfigTab(node); + return this.renderOutcomeConfigTab(node); + } + + private renderSourceConfigTab(node: FlowNode): string { + const d = node.data as SourceNodeData; + let html = ` +
+
+
+
+
+
`; + if (d.sourceType === "card") { + html += ``; + } + return html; + } + + private renderFunnelConfigTab(node: FlowNode): string { const d = node.data as FunnelNodeData; - const lipH = Math.round(s.h * 0.08); - const lipNotch = 14; - const zoneTop = lipH + lipNotch + 4; + const cap = d.maxCapacity || 1; + const sufVal = d.sufficientThreshold ?? d.maxThreshold; + return ` +
+
+
+ Min + + ${this.formatDollar(d.minThreshold)} +
+
+ Suf + + ${this.formatDollar(sufVal)} +
+
+ Max + + ${this.formatDollar(d.maxThreshold)} +
+
+
+
+
`; + } + + private renderOutcomeConfigTab(node: FlowNode): string { + const d = node.data as OutcomeNodeData; + return ` +
+
+
+
+
+
+
+
`; + } + + // ── Analytics tab ── + + private renderInlineAnalyticsTab(node: FlowNode): string { + const stats = this.nodeAnalytics.get(node.id); + + if (node.type === "funnel") { + const d = node.data as FunnelNodeData; + const suf = computeSufficiencyState(d); + const threshold = d.sufficientThreshold ?? d.maxThreshold; + const fillPct = Math.min(100, Math.round((d.currentValue / (threshold || 1)) * 100)); + const fillColor = suf === "seeking" ? "#3b82f6" : suf === "sufficient" ? "#10b981" : "#f59e0b"; + + const totalOut = (stats?.totalOutflow || 0) + (stats?.totalOverflow || 0); + const outflowPct = totalOut > 0 ? Math.round(((stats?.totalOutflow || 0) / totalOut) * 100) : 50; + const overflowPct = 100 - outflowPct; + + return ` +
+
Fill Level${fillPct}%
+
+
+
+ ${suf} +
+ ${totalOut > 0 ? ` +
+
+
+
Outflow ${outflowPct}%
+
Overflow ${overflowPct}%
+
+
` : ""} +
Current Value${this.formatDollar(d.currentValue)}
+
Peak Value${this.formatDollar(stats?.peakValue || d.currentValue)}
+
Avg Fill${this.formatDollar(stats?.avgFillLevel || d.currentValue)}
+
Total Inflow${this.formatDollar(stats?.totalInflow || 0)}
`; + } + + if (node.type === "outcome") { + const d = node.data as OutcomeNodeData; + const progressPct = Math.min(100, Math.round((d.fundingReceived / (d.fundingTarget || 1)) * 100)); + const phasesTotal = d.phases?.length || 0; + const phasesAchieved = d.phases?.filter((p) => d.fundingReceived >= p.fundingThreshold).length || 0; + + return ` +
+
Funding Progress${progressPct}%
+
+
+
Received${this.formatDollar(d.fundingReceived)}
+
Target${this.formatDollar(d.fundingTarget)}
+ ${phasesTotal > 0 ? `
Phases${phasesAchieved} / ${phasesTotal}
` : ""} +
Total Inflow${this.formatDollar(stats?.totalInflow || 0)}
`; + } + + // Source + const d = node.data as SourceNodeData; + return ` +
Flow Rate${this.formatDollar(d.flowRate)}/mo
+
Total Dispensed${this.formatDollar(stats?.totalOutflow || 0)}
+ ${d.targetAllocations.length > 0 ? `
Allocation Breakdown
+ ${d.targetAllocations.map((a) => `
+ + ${this.esc(this.getNodeLabel(a.targetId))} + ${a.percentage}% +
`).join("")}` : ""}`; + } + + // ── Allocations tab ── + + private renderInlineAllocTab(node: FlowNode): string { + const renderRows = (title: string, allocs: { targetId: string; percentage: number; color: string }[]) => { + if (!allocs || allocs.length === 0) return ""; + let html = `
${title}
`; + for (const a of allocs) { + html += `
+ + ${this.esc(this.getNodeLabel(a.targetId))} + ${a.percentage}% +
`; + } + return html; + }; + + if (node.type === "source") { + const d = node.data as SourceNodeData; + const html = renderRows("Target Allocations", d.targetAllocations); + return html || '
No allocations configured
'; + } + if (node.type === "funnel") { + const d = node.data as FunnelNodeData; + let html = renderRows("Spending Allocations", d.spendingAllocations); + html += renderRows("Overflow Allocations", d.overflowAllocations); + return html || '
No allocations configured
'; + } + const od = node.data as OutcomeNodeData; + const html = renderRows("Overflow Allocations", od.overflowAllocations || []); + return html || '
No allocations configured
'; + } + + // ── Funnel threshold markers (SVG on node body) ── + + private renderFunnelThresholdMarkers(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) { + const d = node.data as FunnelNodeData; + const zoneTop = 28; const zoneBot = s.h - 4; const zoneH = zoneBot - zoneTop; - // Label edit - overlay.innerHTML = ` - - - `; - - // Threshold markers const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [ - { key: "minThreshold", value: d.minThreshold, color: "#ef4444", label: "Min" }, - { key: "maxThreshold", value: d.maxThreshold, color: "#f59e0b", label: "Max" }, + { key: "minThreshold", value: d.minThreshold, color: "var(--rflows-status-critical)", label: "Min" }, + { key: "sufficientThreshold", value: d.sufficientThreshold ?? d.maxThreshold, color: "var(--rflows-status-thriving)", label: "Suf" }, + { key: "maxThreshold", value: d.maxThreshold, color: "var(--rflows-status-sustained)", label: "Max" }, ]; - if (d.sufficientThreshold !== undefined) { - thresholds.push({ key: "sufficientThreshold", value: d.sufficientThreshold, color: "#10b981", label: "Suf" }); - } for (const t of thresholds) { const frac = t.value / (d.maxCapacity || 1); const markerY = zoneTop + zoneH * (1 - frac); - overlay.innerHTML += ` - - + + ${t.label} ${this.formatDollar(t.value)}`; } } - private renderSourceInlineEdit(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) { - const d = node.data as SourceNodeData; - overlay.innerHTML = ` - - - - - - `; - } + // ── Inline config listeners ── - private renderOutcomeInlineEdit(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) { - const d = node.data as OutcomeNodeData; - const statusColors: Record = { - "not-started": "#64748b", "in-progress": "#3b82f6", "completed": "#10b981", "blocked": "#ef4444" - }; - const statusList = ["not-started", "in-progress", "completed", "blocked"] as const; - const nextStatus = statusList[(statusList.indexOf(d.status) + 1) % statusList.length]; - - overlay.innerHTML = ` - - - - - - - - ${d.status}`; - } - - private attachInlineEditListeners(g: SVGGElement, node: FlowNode) { + private attachInlineConfigListeners(g: SVGGElement, node: FlowNode) { const overlay = g.querySelector(".inline-edit-overlay"); if (!overlay) return; - // Input fields - overlay.querySelectorAll("input[data-inline-field]").forEach((el) => { - const input = el as HTMLInputElement; - const field = input.dataset.inlineField!; - input.addEventListener("input", () => { - const val = input.type === "number" ? parseFloat(input.value) || 0 : input.value; + // Tab switching + overlay.querySelectorAll(".icp-tab").forEach((el) => { + el.addEventListener("click", (e: Event) => { + e.stopPropagation(); + const tab = (el as HTMLElement).dataset.icpTab as "config" | "analytics" | "allocations"; + if (!tab || tab === this.inlineConfigTab) return; + this.inlineConfigTab = tab; + overlay.querySelectorAll(".icp-tab").forEach((t) => t.classList.remove("icp-tab--active")); + el.classList.add("icp-tab--active"); + const body = overlay.querySelector(".icp-body") as HTMLElement; + if (body) body.innerHTML = this.renderInlineConfigContent(node); + this.attachInlineConfigFieldListeners(overlay as Element, node); + }); + }); + + // Field listeners + this.attachInlineConfigFieldListeners(overlay, node); + + // Threshold drag handles (funnel) + this.attachThresholdDragListeners(overlay, node); + + // Done button + overlay.querySelector(".iet-done")?.addEventListener("click", (e: Event) => { + e.stopPropagation(); + this.exitInlineEdit(); + }); + + // Delete button + overlay.querySelector(".iet-delete")?.addEventListener("click", (e: Event) => { + e.stopPropagation(); + this.deleteNode(node.id); + this.exitInlineEdit(); + }); + + // "..." panel button + overlay.querySelector(".iet-panel")?.addEventListener("click", (e: Event) => { + e.stopPropagation(); + this.exitInlineEdit(); + this.openEditor(node.id); + }); + + // Fund Now button (source card type) + overlay.querySelector("[data-icp-action='fund']")?.addEventListener("click", (e: Event) => { + e.stopPropagation(); + const sd = node.data as SourceNodeData; + const flowId = this.flowId || this.getAttribute("flow-id") || ""; + if (!sd.walletAddress) { + alert("Configure a wallet address first"); + return; + } + this.openTransakWidget(flowId, sd.walletAddress); + }); + + // Click-outside handler + const clickOutsideHandler = (e: PointerEvent) => { + const target = e.target as Element; + if (!target.closest(`[data-node-id="${node.id}"]`)) { + this.exitInlineEdit(); + document.removeEventListener("pointerdown", clickOutsideHandler as EventListener, true); + } + }; + setTimeout(() => { + document.addEventListener("pointerdown", clickOutsideHandler as EventListener, true); + }, 100); + } + + private attachInlineConfigFieldListeners(overlay: Element, node: FlowNode) { + // Text/number/select input fields + overlay.querySelectorAll("[data-icp-field]").forEach((el) => { + const input = el as HTMLInputElement | HTMLSelectElement; + const field = input.dataset.icpField!; + const handler = () => { + const numFields = ["flowRate", "currentValue", "minThreshold", "maxThreshold", "maxCapacity", "inflowRate", "sufficientThreshold", "fundingReceived", "fundingTarget"]; + const val = numFields.includes(field) ? parseFloat((input as HTMLInputElement).value) || 0 : input.value; (node.data as any)[field] = val; - // Re-render the node (but not the overlay) this.redrawNodeOnly(node); this.redrawEdges(); - }); + }; + input.addEventListener("input", handler); + input.addEventListener("change", handler); input.addEventListener("keydown", (e: Event) => { const ke = e as KeyboardEvent; if (ke.key === "Enter") this.exitInlineEdit(); @@ -2086,7 +2379,23 @@ class FolkFlowsApp extends HTMLElement { }); }); - // Threshold drag handles + // Range sliders + overlay.querySelectorAll("[data-icp-range]").forEach((el) => { + const input = el as HTMLInputElement; + const field = input.dataset.icpRange!; + input.addEventListener("input", () => { + const val = parseFloat(input.value) || 0; + (node.data as any)[field] = Math.round(val); + const valueSpan = input.parentElement?.querySelector(".icp-range-value") as HTMLElement; + if (valueSpan) valueSpan.textContent = this.formatDollar(val); + this.redrawNodeOnly(node); + this.redrawEdges(); + this.redrawThresholdMarkers(node); + }); + }); + } + + private attachThresholdDragListeners(overlay: Element, node: FlowNode) { overlay.querySelectorAll(".threshold-handle").forEach((el) => { el.addEventListener("pointerdown", (e: Event) => { const pe = e as PointerEvent; @@ -2103,68 +2412,44 @@ class FolkFlowsApp extends HTMLElement { const pe = e as PointerEvent; const d = node.data as FunnelNodeData; const s = this.getNodeSize(node); - const lipH = Math.round(s.h * 0.08); - const lipNotch = 14; - const zoneH = s.h - 4 - (lipH + lipNotch + 4); - // Pixels to dollar conversion + const zoneH = s.h - 4 - 28; const deltaY = (pe.clientY - this.inlineEditDragStartY) / this.canvasZoom; const deltaDollars = -(deltaY / zoneH) * (d.maxCapacity || 1); let newVal = this.inlineEditDragStartValue + deltaDollars; - // Constrain: 0 ≤ min ≤ sufficient ≤ max ≤ capacity newVal = Math.max(0, Math.min(d.maxCapacity, newVal)); const key = this.inlineEditDragThreshold; if (key === "minThreshold") newVal = Math.min(newVal, d.maxThreshold); if (key === "maxThreshold") newVal = Math.max(newVal, d.minThreshold); if (key === "sufficientThreshold") newVal = Math.max(d.minThreshold, Math.min(d.maxThreshold, newVal)); (node.data as any)[key] = Math.round(newVal); - // Update display this.redrawNodeInlineEdit(node); }); el.addEventListener("pointerup", () => { this.inlineEditDragThreshold = null; }); }); + } - // Done button - overlay.querySelector(".iet-done")?.addEventListener("click", (e: Event) => { - e.stopPropagation(); - this.exitInlineEdit(); + private redrawThresholdMarkers(node: FlowNode) { + if (node.type !== "funnel") return; + const nodeLayer = this.shadow.getElementById("node-layer"); + const g = nodeLayer?.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null; + if (!g) return; + const overlay = g.querySelector(".inline-edit-overlay"); + if (!overlay) return; + overlay.querySelectorAll(".threshold-marker, .threshold-handle").forEach((el) => el.remove()); + overlay.querySelectorAll("text").forEach((t) => { + if (t.getAttribute("pointer-events") === "none" && t.getAttribute("font-size") === "9") t.remove(); }); - - // Delete button - overlay.querySelector(".iet-delete")?.addEventListener("click", (e: Event) => { - e.stopPropagation(); - this.deleteNode(node.id); - this.exitInlineEdit(); - }); - - // "..." panel fallback button - overlay.querySelector(".iet-panel")?.addEventListener("click", (e: Event) => { - e.stopPropagation(); - this.exitInlineEdit(); - this.openEditor(node.id); - }); - - // Status badge cycling (outcome) - overlay.querySelector("[data-inline-action='cycle-status']")?.addEventListener("click", (e: Event) => { - e.stopPropagation(); - const d = node.data as OutcomeNodeData; - const statusList = ["not-started", "in-progress", "completed", "blocked"] as const; - d.status = statusList[(statusList.indexOf(d.status) + 1) % statusList.length]; - this.redrawNodeInlineEdit(node); - }); - - // Click-outside handler to exit inline edit - const clickOutsideHandler = (e: PointerEvent) => { - const target = e.target as Element; - if (!target.closest(`[data-node-id="${node.id}"]`)) { - this.exitInlineEdit(); - document.removeEventListener("pointerdown", clickOutsideHandler as EventListener, true); - } - }; - setTimeout(() => { - document.addEventListener("pointerdown", clickOutsideHandler as EventListener, true); - }, 100); + const s = this.getNodeSize(node); + const tempG = document.createElementNS("http://www.w3.org/2000/svg", "g"); + this.renderFunnelThresholdMarkers(tempG, node, s); + const fo = overlay.querySelector("foreignObject"); + while (tempG.firstChild) { + if (fo) overlay.insertBefore(tempG.firstChild, fo); + else overlay.appendChild(tempG.firstChild); + } + this.attachThresholdDragListeners(overlay, node); } private redrawNodeOnly(node: FlowNode) { @@ -2174,7 +2459,6 @@ class FolkFlowsApp extends HTMLElement { if (!g) return; const satisfaction = this.computeInflowSatisfaction(); const newSvg = this.renderNodeSvg(node, satisfaction); - // Parse and replace, preserving inline edit overlay const overlay = g.querySelector(".inline-edit-overlay"); const temp = document.createElementNS("http://www.w3.org/2000/svg", "g"); temp.innerHTML = newSvg; @@ -2188,38 +2472,43 @@ class FolkFlowsApp extends HTMLElement { } private redrawNodeInlineEdit(node: FlowNode) { - // Re-render the whole node + re-enter inline edit this.drawCanvasContent(); - const s = this.getNodeSize(node); const nodeLayer = this.shadow.getElementById("node-layer"); const g = nodeLayer?.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null; if (!g) return; g.querySelector(".inline-edit-overlay")?.remove(); + const s = this.getNodeSize(node); const overlay = document.createElementNS("http://www.w3.org/2000/svg", "g"); overlay.classList.add("inline-edit-overlay"); if (node.type === "funnel") { - this.renderFunnelInlineEdit(overlay, node, s); - } else if (node.type === "source") { - this.renderSourceInlineEdit(overlay, node, s); - } else { - this.renderOutcomeInlineEdit(overlay, node, s); + this.renderFunnelThresholdMarkers(overlay, node, s); } - // Toolbar - const toolbarY = s.h + 8; + const panelW = Math.max(280, s.w); + const panelH = 260; + const panelX = (s.w - panelW) / 2; + const panelY = s.h + 8; + + const tabs = ["config", "analytics", "allocations"] as const; overlay.innerHTML += ` - -
- - - + +
+
+ ${tabs.map((t) => ``).join("")} +
+
${this.renderInlineConfigContent(node)}
+
+ + + +
`; g.appendChild(overlay); - this.attachInlineEditListeners(g, node); + this.attachInlineConfigListeners(g, node); } private exitInlineEdit() { @@ -2229,10 +2518,55 @@ class FolkFlowsApp extends HTMLElement { if (g) g.querySelector(".inline-edit-overlay")?.remove(); this.inlineEditNodeId = null; this.inlineEditDragThreshold = null; - // Re-render to apply any changes this.drawCanvasContent(); } + // ── Analytics accumulation ── + + private accumulateNodeAnalytics() { + for (const node of this.nodes) { + let stats = this.nodeAnalytics.get(node.id); + if (!stats) { + stats = { totalInflow: 0, totalOutflow: 0, totalOverflow: 0, avgFillLevel: 0, peakValue: 0, outcomesAchieved: 0, tickCount: 0, fillLevelSum: 0 }; + this.nodeAnalytics.set(node.id, stats); + } + stats.tickCount++; + + if (node.type === "funnel") { + const d = node.data as FunnelNodeData; + stats.totalInflow += d.inflowRate; + const threshold = d.sufficientThreshold ?? d.maxThreshold; + if (d.currentValue >= d.maxCapacity) { + stats.totalOverflow += d.inflowRate * 0.5; + stats.totalOutflow += d.inflowRate * 0.5; + } else if (d.currentValue >= threshold) { + stats.totalOutflow += d.inflowRate * 0.3; + } + stats.fillLevelSum += d.currentValue; + stats.avgFillLevel = stats.fillLevelSum / stats.tickCount; + stats.peakValue = Math.max(stats.peakValue, d.currentValue); + } else if (node.type === "outcome") { + const d = node.data as OutcomeNodeData; + stats.peakValue = Math.max(stats.peakValue, d.fundingReceived); + stats.outcomesAchieved = d.phases?.filter((p) => d.fundingReceived >= p.fundingThreshold).length || 0; + } else if (node.type === "source") { + const d = node.data as SourceNodeData; + stats.totalOutflow += d.flowRate / 10; + } + } + } + + private updateInlineConfigAnalytics() { + if (!this.inlineEditNodeId || this.inlineConfigTab !== "analytics") return; + const node = this.nodes.find((n) => n.id === this.inlineEditNodeId); + if (!node) return; + const nodeLayer = this.shadow.getElementById("node-layer"); + const g = nodeLayer?.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null; + if (!g) return; + const body = g.querySelector(".icp-body") as HTMLElement | null; + if (body) body.innerHTML = this.renderInlineAnalyticsTab(node); + } + private refreshEditorIfOpen(nodeId: string) { if (this.editingNodeId === nodeId) this.openEditor(nodeId); } @@ -2262,21 +2596,27 @@ class FolkFlowsApp extends HTMLElement { private renderFunnelEditor(n: FlowNode): string { const d = n.data as FunnelNodeData; + const derived = d.desiredOutflow ? deriveThresholds(d.desiredOutflow) : null; return `
+
+
-
-
-
-
-
-
-
+
-
-
+
+
Thresholds ${derived ? "(auto-derived from outflow)" : ""}
+
+
+
+
+
+
+
+
+
${this.renderAllocEditor("Overflow Allocations", d.overflowAllocations)} ${this.renderAllocEditor("Spending Allocations", d.spendingAllocations)}`; } @@ -2300,7 +2640,7 @@ class FolkFlowsApp extends HTMLElement { html += `
Phases
`; for (const p of d.phases) { const unlocked = d.fundingReceived >= p.fundingThreshold; - html += `
+ html += `
${this.esc(p.name)} — $${p.fundingThreshold.toLocaleString()}
${p.tasks.map((t) => `
${t.completed ? "✅" : "⬜"} ${this.esc(t.label)}
`).join("")}
`; @@ -2400,8 +2740,22 @@ class FolkFlowsApp extends HTMLElement { const field = (input as HTMLElement).dataset.field; if (!field) return; const val = (input as HTMLInputElement).value; - const numFields = ["flowRate", "currentValue", "minThreshold", "maxThreshold", "maxCapacity", "inflowRate", "sufficientThreshold", "fundingReceived", "fundingTarget"]; + const numFields = ["flowRate", "currentValue", "minThreshold", "maxThreshold", "maxCapacity", "inflowRate", "sufficientThreshold", "fundingReceived", "fundingTarget", "desiredOutflow"]; (node.data as any)[field] = numFields.includes(field) ? parseFloat(val) || 0 : val; + // Auto-derive thresholds when desiredOutflow changes + if (field === "desiredOutflow" && node.type === "funnel") { + const fd = node.data as FunnelNodeData; + if (fd.desiredOutflow) { + const derived = deriveThresholds(fd.desiredOutflow); + fd.minThreshold = derived.minThreshold; + fd.sufficientThreshold = derived.sufficientThreshold; + fd.maxThreshold = derived.maxThreshold; + fd.maxCapacity = derived.maxCapacity; + // Re-render the editor to reflect updated values + this.openEditor(node.id); + return; + } + } this.drawCanvasContent(); this.updateSufficiencyBadge(); }); @@ -2437,7 +2791,7 @@ class FolkFlowsApp extends HTMLElement { const d = node.data as FunnelNodeData; const suf = computeSufficiencyState(d); html += `
$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.sufficientThreshold ?? d.maxThreshold).toLocaleString()}
`; - html += `
${suf}
`; + html += `
${suf}
`; } else { const d = node.data as OutcomeNodeData; const pct = d.fundingTarget > 0 ? Math.round((d.fundingReceived / d.fundingTarget) * 100) : 0; @@ -2489,9 +2843,9 @@ class FolkFlowsApp extends HTMLElement { const d = node.data as OutcomeNodeData; const fillPct = d.fundingTarget > 0 ? Math.min(100, (d.fundingReceived / d.fundingTarget) * 100) : 0; - const statusColor = d.status === "completed" ? "#10b981" - : d.status === "blocked" ? "#ef4444" - : d.status === "in-progress" ? "#3b82f6" : "#64748b"; + const statusColor = d.status === "completed" ? "var(--rflows-status-completed)" + : d.status === "blocked" ? "var(--rflows-status-blocked)" + : d.status === "in-progress" ? "var(--rflows-status-inprogress)" : "var(--rflows-status-notstarted)"; const statusLabel = d.status === "completed" ? "Completed" : d.status === "blocked" ? "Blocked" : d.status === "in-progress" ? "In Progress" : "Not Started"; @@ -2501,7 +2855,7 @@ class FolkFlowsApp extends HTMLElement { phasesHtml += `
`; for (const p of d.phases) { const unlocked = d.fundingReceived >= p.fundingThreshold; - phasesHtml += `
`; + phasesHtml += `
`; } phasesHtml += `
`; @@ -2526,7 +2880,7 @@ class FolkFlowsApp extends HTMLElement { $${Math.floor(Math.min(d.fundingReceived, p.fundingThreshold)).toLocaleString()} / $${p.fundingThreshold.toLocaleString()}
-
+
${p.tasks.map((t, ti) => ` @@ -2654,7 +3008,7 @@ class FolkFlowsApp extends HTMLElement {
-
`; @@ -2715,8 +3069,8 @@ class FolkFlowsApp extends HTMLElement {
${configHtml}
-
- +
+
`; @@ -2800,8 +3154,9 @@ class FolkFlowsApp extends HTMLElement { data = { label: "New Source", flowRate: 1000, sourceType: "card", targetAllocations: [] } as SourceNodeData; } else if (type === "funnel") { data = { - label: "New Funnel", currentValue: 0, minThreshold: 5000, maxThreshold: 20000, - maxCapacity: 30000, inflowRate: 500, sufficientThreshold: 15000, dynamicOverflow: false, + label: "New Funnel", currentValue: 0, desiredOutflow: 5000, + minThreshold: 5000, sufficientThreshold: 15000, maxThreshold: 30000, + maxCapacity: 45000, inflowRate: 0, dynamicOverflow: false, overflowAllocations: [], spendingAllocations: [], } as FunnelNodeData; } else { @@ -2853,6 +3208,7 @@ class FolkFlowsApp extends HTMLElement { if (this.isSimulating) { this.simTickCount = 0; + this.nodeAnalytics.clear(); this.startSimInterval(); } else { if (this.simInterval) { clearInterval(this.simInterval); this.simInterval = null; } @@ -2864,6 +3220,7 @@ class FolkFlowsApp extends HTMLElement { this.simInterval = setInterval(() => { this.simTickCount++; this.nodes = simulateTick(this.nodes); + this.accumulateNodeAnalytics(); this.updateCanvasLive(); }, this.simSpeedMs); } @@ -2880,9 +3237,7 @@ class FolkFlowsApp extends HTMLElement { const d = n.data as FunnelNodeData; const s = this.getNodeSize(n); const h = s.h; - const lipH = Math.round(h * 0.08); - const lipNotch = 14; - const zoneTop = lipH + lipNotch + 4; + const zoneTop = 28; const zoneBot = h - 4; const zoneH = zoneBot - zoneTop; const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1)); @@ -2902,22 +3257,33 @@ class FolkFlowsApp extends HTMLElement { } } - // Full rebuild for structural changes (new nodes, edges, text labels) - if (!didPatch) { - nodeLayer.innerHTML = this.renderAllNodes(); - } else { - // Rebuild only things that change structurally - nodeLayer.innerHTML = this.renderAllNodes(); + // Preserve inline config overlay during rebuild + let overlayNodeId: string | null = null; + let detachedOverlay: Element | null = null; + if (this.inlineEditNodeId) { + overlayNodeId = this.inlineEditNodeId; + const existingG = nodeLayer.querySelector(`[data-node-id="${overlayNodeId}"]`); + detachedOverlay = existingG?.querySelector(".inline-edit-overlay") || null; + if (detachedOverlay) detachedOverlay.remove(); } + + nodeLayer.innerHTML = this.renderAllNodes(); + + // Reattach overlay to the new node + if (detachedOverlay && overlayNodeId) { + const newG = nodeLayer.querySelector(`[data-node-id="${overlayNodeId}"]`); + if (newG) newG.appendChild(detachedOverlay); + } + this.redrawEdges(); this.updateSufficiencyBadge(); + this.updateInlineConfigAnalytics(); // Update timeline bar const tickLabel = this.shadow.getElementById("timeline-tick"); const timelineFill = this.shadow.getElementById("timeline-fill"); if (tickLabel) tickLabel.textContent = `Tick ${this.simTickCount}`; if (timelineFill) { - // Loop progress over 100 ticks const pct = (this.simTickCount % 100); timelineFill.style.width = `${pct}%`; } @@ -2926,7 +3292,7 @@ class FolkFlowsApp extends HTMLElement { private updateSufficiencyBadge() { const score = computeSystemSufficiency(this.nodes); const scorePct = Math.round(score * 100); - const scoreColor = scorePct >= 90 ? "#fbbf24" : scorePct >= 60 ? "#10b981" : scorePct >= 30 ? "#f59e0b" : "#ef4444"; + const scoreColor = scorePct >= 90 ? "var(--rflows-score-gold)" : scorePct >= 60 ? "var(--rflows-score-green)" : scorePct >= 30 ? "var(--rflows-score-amber)" : "var(--rflows-score-red)"; const badge = this.shadow.getElementById("badge-score"); if (badge) { badge.textContent = `${scorePct}%`; diff --git a/modules/rflows/lib/presets.ts b/modules/rflows/lib/presets.ts index b47cef2..2346d58 100644 --- a/modules/rflows/lib/presets.ts +++ b/modules/rflows/lib/presets.ts @@ -18,8 +18,9 @@ export const demoNodes: FlowNode[] = [ { id: "treasury", type: "funnel", position: { x: 630, y: 0 }, data: { - label: "Treasury", currentValue: 85000, minThreshold: 20000, maxThreshold: 70000, - maxCapacity: 100000, inflowRate: 1000, sufficientThreshold: 60000, dynamicOverflow: true, + label: "Treasury", currentValue: 85000, desiredOutflow: 10000, + minThreshold: 10000, sufficientThreshold: 30000, maxThreshold: 60000, + maxCapacity: 90000, inflowRate: 1000, dynamicOverflow: true, overflowAllocations: [ { targetId: "public-goods", percentage: 40, color: OVERFLOW_COLORS[0] }, { targetId: "research", percentage: 35, color: OVERFLOW_COLORS[1] }, @@ -33,8 +34,9 @@ export const demoNodes: FlowNode[] = [ { id: "public-goods", type: "funnel", position: { x: 170, y: 450 }, data: { - label: "Public Goods", currentValue: 45000, minThreshold: 15000, maxThreshold: 50000, - maxCapacity: 70000, inflowRate: 400, sufficientThreshold: 42000, + label: "Public Goods", currentValue: 45000, desiredOutflow: 7000, + minThreshold: 7000, sufficientThreshold: 21000, maxThreshold: 42000, + maxCapacity: 63000, inflowRate: 400, overflowAllocations: [], spendingAllocations: [ { targetId: "pg-infra", percentage: 50, color: SPENDING_COLORS[0] }, @@ -46,8 +48,9 @@ export const demoNodes: FlowNode[] = [ { id: "research", type: "funnel", position: { x: 975, y: 450 }, data: { - label: "Research", currentValue: 28000, minThreshold: 20000, maxThreshold: 45000, - maxCapacity: 60000, inflowRate: 350, sufficientThreshold: 38000, + label: "Research", currentValue: 28000, desiredOutflow: 5000, + minThreshold: 5000, sufficientThreshold: 15000, maxThreshold: 30000, + maxCapacity: 45000, inflowRate: 350, overflowAllocations: [], spendingAllocations: [ { targetId: "research-grants", percentage: 70, color: SPENDING_COLORS[0] }, @@ -58,8 +61,9 @@ export const demoNodes: FlowNode[] = [ { id: "emergency", type: "funnel", position: { x: 1320, y: 450 }, data: { - label: "Emergency", currentValue: 12000, minThreshold: 25000, maxThreshold: 60000, - maxCapacity: 80000, inflowRate: 250, sufficientThreshold: 50000, + label: "Emergency", currentValue: 12000, desiredOutflow: 8000, + minThreshold: 8000, sufficientThreshold: 24000, maxThreshold: 48000, + maxCapacity: 72000, inflowRate: 250, overflowAllocations: [], spendingAllocations: [ { targetId: "emergency-response", percentage: 100, color: SPENDING_COLORS[0] }, diff --git a/modules/rflows/lib/simulation.ts b/modules/rflows/lib/simulation.ts index 66d9f6f..b0352e5 100644 --- a/modules/rflows/lib/simulation.ts +++ b/modules/rflows/lib/simulation.ts @@ -4,6 +4,7 @@ */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, SufficiencyState } from "./types"; +import { deriveThresholds } from "./types"; export interface SimulationConfig { tickDivisor: number; @@ -102,6 +103,15 @@ export function simulateTick( const src = node.data as FunnelNodeData; const data: FunnelNodeData = { ...src }; + // Auto-derive thresholds from desiredOutflow when present + if (data.desiredOutflow) { + const derived = deriveThresholds(data.desiredOutflow); + data.minThreshold = derived.minThreshold; + data.sufficientThreshold = derived.sufficientThreshold; + data.maxThreshold = derived.maxThreshold; + data.maxCapacity = derived.maxCapacity; + } + let value = data.currentValue + data.inflowRate / tickDivisor; value += overflowIncoming.get(node.id) ?? 0; value = Math.min(value, data.maxCapacity); @@ -136,7 +146,8 @@ export function simulateTick( rateMultiplier = spendingRateCritical; } - let drain = (data.inflowRate / tickDivisor) * rateMultiplier; + const baseRate = data.desiredOutflow || data.inflowRate; + let drain = (baseRate / tickDivisor) * rateMultiplier; drain = Math.min(drain, value); value -= drain; diff --git a/modules/rflows/lib/types.ts b/modules/rflows/lib/types.ts index 013dff5..a7acd00 100644 --- a/modules/rflows/lib/types.ts +++ b/modules/rflows/lib/types.ts @@ -46,6 +46,7 @@ export interface FunnelNodeData { maxThreshold: number; maxCapacity: number; inflowRate: number; + desiredOutflow?: number; sufficientThreshold?: number; dynamicOverflow?: boolean; overflowAllocations: OverflowAllocation[]; @@ -54,6 +55,15 @@ export interface FunnelNodeData { [key: string]: unknown; } +export function deriveThresholds(desiredOutflow: number) { + return { + minThreshold: desiredOutflow * 1, // 1 month runway + sufficientThreshold: desiredOutflow * 3, // 3 months runway + maxThreshold: desiredOutflow * 6, // overflow point + maxCapacity: desiredOutflow * 9, // visual max + }; +} + export interface PhaseTask { label: string; completed: boolean; @@ -123,8 +133,8 @@ export const PORT_DEFS: Record = { ], funnel: [ { kind: "inflow", dir: "in", xFrac: 0.5, yFrac: 0, color: "#60a5fa" }, - { kind: "overflow", dir: "out", xFrac: 0.0, yFrac: 0.15, color: "#f59e0b", connectsTo: ["inflow"], side: "left" }, - { kind: "overflow", dir: "out", xFrac: 1.0, yFrac: 0.15, color: "#f59e0b", connectsTo: ["inflow"], side: "right" }, + { kind: "overflow", dir: "out", xFrac: 0.0, yFrac: 0.55, color: "#f59e0b", connectsTo: ["inflow"], side: "left" }, + { kind: "overflow", dir: "out", xFrac: 1.0, yFrac: 0.55, color: "#f59e0b", connectsTo: ["inflow"], side: "right" }, { kind: "spending", dir: "out", xFrac: 0.5, yFrac: 1, color: "#8b5cf6", connectsTo: ["inflow"] }, ], outcome: [ diff --git a/modules/rsplat/components/splat.css b/modules/rsplat/components/splat.css index 4b6e5a3..129d2ab 100644 --- a/modules/rsplat/components/splat.css +++ b/modules/rsplat/components/splat.css @@ -1,14 +1,14 @@ /* rSplat — Gaussian Splat Viewer */ :root { - --splat-bg: #0f172a; - --splat-surface: #1e293b; - --splat-border: #334155; - --splat-text: #e2e8f0; - --splat-text-muted: #94a3b8; + --splat-bg: var(--rs-bg-page, #0f172a); + --splat-surface: var(--rs-bg-surface, #1e293b); + --splat-border: var(--rs-border-strong, #334155); + --splat-text: var(--rs-text-primary, #e2e8f0); + --splat-text-muted: var(--rs-text-muted, #94a3b8); --splat-accent: #818cf8; --splat-accent-hover: #6366f1; - --splat-card-bg: rgba(30, 41, 59, 0.8); + --splat-card-bg: var(--rs-bg-surface, rgba(30, 41, 59, 0.8)); } /* ── Gallery ── */ @@ -319,7 +319,7 @@ position: relative; width: 100%; height: calc(100vh - 56px); - background: #111827; + background: var(--splat-bg); overflow: hidden; } @@ -344,7 +344,7 @@ gap: 0.375rem; padding: 0.5rem 0.75rem; border-radius: 8px; - background: rgba(30, 41, 59, 0.85); + background: var(--rs-glass-bg, rgba(30, 41, 59, 0.85)); backdrop-filter: blur(8px); color: var(--splat-text); text-decoration: none; @@ -354,7 +354,7 @@ } .splat-viewer__back:hover { - background: rgba(51, 65, 85, 0.9); + background: var(--rs-bg-surface, rgba(51, 65, 85, 0.9)); } .splat-viewer__info { @@ -364,7 +364,7 @@ z-index: 10; padding: 0.75rem 1rem; border-radius: 8px; - background: rgba(30, 41, 59, 0.85); + background: var(--rs-glass-bg, rgba(30, 41, 59, 0.85)); backdrop-filter: blur(8px); border: 1px solid var(--splat-border); color: var(--splat-text); @@ -391,7 +391,7 @@ flex-direction: column; align-items: center; justify-content: center; - background: #111827; + background: var(--splat-bg); z-index: 20; transition: opacity 0.4s; } @@ -444,6 +444,46 @@ margin: 0; } +/* ── Light theme overrides ── */ + +[data-theme="light"] .splat-card__preview { + background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 50%, #f1f5f9 100%); +} + +[data-theme="light"] .splat-card--pending .splat-card__preview { + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 50%, #f1f5f9 100%); +} + +[data-theme="light"] .splat-card--processing .splat-card__preview { + background: linear-gradient(135deg, #e0e7ff 0%, #bfdbfe 50%, #f1f5f9 100%); +} + +[data-theme="light"] .splat-card--failed .splat-card__preview { + background: linear-gradient(135deg, #fee2e2 0%, #fecaca 50%, #f1f5f9 100%); +} + +[data-theme="light"] .splat-card:hover { + box-shadow: 0 8px 24px rgba(99, 102, 241, 0.12); +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme]) .splat-card__preview { + background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 50%, #f1f5f9 100%); + } + :root:not([data-theme]) .splat-card--pending .splat-card__preview { + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 50%, #f1f5f9 100%); + } + :root:not([data-theme]) .splat-card--processing .splat-card__preview { + background: linear-gradient(135deg, #e0e7ff 0%, #bfdbfe 50%, #f1f5f9 100%); + } + :root:not([data-theme]) .splat-card--failed .splat-card__preview { + background: linear-gradient(135deg, #fee2e2 0%, #fecaca 50%, #f1f5f9 100%); + } + :root:not([data-theme]) .splat-card:hover { + box-shadow: 0 8px 24px rgba(99, 102, 241, 0.12); + } +} + /* ── Responsive ── */ @media (max-width: 640px) {