Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-03 21:05:33 -08:00
commit f76615a890
2 changed files with 150 additions and 117 deletions

View File

@ -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; }
}

View File

@ -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 '<div class="flows-loading">Loading...</div>';
}
return `
<div class="flows-detail">
<div class="rapp-nav">
<a href="${this.esc(backUrl)}" class="rapp-nav__back">&larr; Flows</a>
<span class="rapp-nav__title">${this.esc(this.flowName || "Flow Detail")}</span>
${this.isDemo ? '<span class="rapp-nav__badge">Demo</span>' : ""}
</div>
<div class="flows-tabs">
<button class="flows-tab ${this.tab === "diagram" ? "flows-tab--active" : ""}" data-tab="diagram">Diagram</button>
<button class="flows-tab ${this.tab === "river" ? "flows-tab--active" : ""}" data-tab="river">River</button>
<button class="flows-tab ${this.tab === "table" ? "flows-tab--active" : ""}" data-tab="table">Table</button>
<button class="flows-tab ${this.tab === "transactions" ? "flows-tab--active" : ""}" data-tab="transactions">Transactions</button>
</div>
<div class="flows-tab-content">
${this.loading ? '<div class="flows-loading">Loading...</div>' : this.renderTab()}
</div>
<div class="flows-detail flows-detail--fullpage">
${this.renderDiagramTab()}
</div>`;
}
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 '<div class="flows-loading">No nodes to display.</div>';
}
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 `
<div class="flows-canvas-container" id="canvas-container">
<div class="flows-canvas-container flows-canvas-container--fullpage" id="canvas-container">
<div class="flows-nav-overlay">
<a href="${this.esc(backUrl)}" class="rapp-nav__back">&larr; Flows</a>
<span class="rapp-nav__title">${this.esc(this.flowName || "Flow Detail")}</span>
${this.isDemo ? '<span class="rapp-nav__badge">Demo</span>' : ""}
</div>
<div class="flows-canvas-badge" id="canvas-badge">
<div>
<div class="flows-canvas-badge__score" id="badge-score" style="color:${scoreColor}">${scorePct}%</div>
@ -581,6 +568,7 @@ class FolkFlowsApp extends HTMLElement {
<div class="flows-canvas-sep"></div>
<button class="flows-canvas-btn" data-canvas-action="sim" id="sim-btn">${this.isSimulating ? "Pause" : "Play"}</button>
<button class="flows-canvas-btn" data-canvas-action="fit">Fit</button>
<button class="flows-canvas-btn ${this.analyticsOpen ? "flows-canvas-btn--active" : ""}" data-canvas-action="analytics">Analytics</button>
<button class="flows-canvas-btn" data-canvas-action="share">Share</button>
</div>
<svg class="flows-canvas-svg" id="flow-canvas">
@ -591,13 +579,14 @@ class FolkFlowsApp extends HTMLElement {
</g>
</svg>
<div class="flows-editor-panel" id="editor-panel"></div>
${this.renderAnalyticsPanel()}
<div class="flows-canvas-legend">
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#10b981"></span>Source</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#0ea5e9"></span>Funnel</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#f59e0b"></span>Overflow</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#8b5cf6"></span>Spending</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#3b82f6"></span>Outcome</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#fbbf24"></span>Sufficient</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#10b981"></span>Inflow</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#34d399"></span>Spending</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#6ee7b7"></span>Overflow</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#ef4444;border-radius:50%"></span>Critical</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#f59e0b;border-radius:50%"></span>Sustained</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#10b981;border-radius:50%"></span>Thriving</span>
</div>
<div class="flows-canvas-zoom">
<button class="flows-canvas-btn" data-canvas-action="zoom-in">+</button>
@ -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 `<g class="flow-node${glowClass} ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})" style="${glowStyle}">
<defs>
<clipPath id="${clipId}"><path d="${funnelPath}"/></clipPath>
</defs>
${isSufficient ? `<path d="${funnelPath}" fill="none" stroke="#fbbf24" stroke-width="2" opacity="0.4" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""}
${isOverflow ? `<path d="${funnelPath}" fill="none" stroke="#10b981" stroke-width="2" opacity="0.4" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""}
<path class="node-bg" d="${funnelPath}" fill="#1e293b" stroke="${selected ? "#6366f1" : borderColor}" stroke-width="${selected ? 3 : 2}"/>
<g clip-path="url(#${clipId})">
<rect x="${-lipW}" y="${zoneTop + overflowH + healthyH}" width="${w + lipW * 2}" height="${drainH}" fill="#ef4444" opacity="0.08"/>
@ -1168,10 +1155,10 @@ class FolkFlowsApp extends HTMLElement {
<rect x="${-lipW}" y="${zoneTop}" width="${w + lipW * 2}" height="${overflowH}" fill="#f59e0b" opacity="0.06"/>
<rect x="${-lipW}" y="${fillY}" width="${w + lipW * 2}" height="${totalFillH}" fill="${fillColor}" opacity="0.25"/>
</g>
<rect class="funnel-lip ${isAbundant ? "funnel-lip--active" : ""}" x="${-lipW}" y="${lipH}" width="${lipW}" height="${lipNotch}" rx="2" fill="${isAbundant ? "#f59e0b" : "#334155"}" opacity="${isAbundant ? 0.8 : 0.3}"/>
<rect class="funnel-lip ${isAbundant ? "funnel-lip--active" : ""}" x="${w}" y="${lipH}" width="${lipW}" height="${lipNotch}" rx="2" fill="${isAbundant ? "#f59e0b" : "#334155"}" opacity="${isAbundant ? 0.8 : 0.3}"/>
<rect class="funnel-lip ${isOverflow ? "funnel-lip--active" : ""}" x="${-lipW}" y="${lipH}" width="${lipW}" height="${lipNotch}" rx="2" fill="${isOverflow ? "#10b981" : "#334155"}" opacity="${isOverflow ? 0.8 : 0.3}"/>
<rect class="funnel-lip ${isOverflow ? "funnel-lip--active" : ""}" x="${w}" y="${lipH}" width="${lipW}" height="${lipNotch}" rx="2" fill="${isOverflow ? "#10b981" : "#334155"}" opacity="${isOverflow ? 0.8 : 0.3}"/>
<text x="${w / 2}" y="${lipH + 6}" text-anchor="middle" fill="#e2e8f0" font-size="13" font-weight="600">${this.esc(d.label)}</text>
<text x="${w - 10}" y="${lipH + 6}" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500" class="${isSufficient ? "sufficiency-glow" : ""}">${statusLabel}</text>
<text x="${w - 10}" y="${lipH + 6}" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500" class="${!isCritical ? "sufficiency-glow" : ""}">${statusLabel}</text>
<rect x="20" y="${satBarY}" width="${satBarW}" height="6" rx="3" fill="#334155" opacity="0.3" class="satisfaction-bar-bg"/>
<rect x="20" y="${satBarY}" width="${satFillW}" height="6" rx="3" fill="#10b981" class="satisfaction-bar-fill" ${satBarBorder}/>
<text x="${w / 2}" y="${satBarY + 16}" text-anchor="middle" fill="#64748b" font-size="9">${satLabel}</text>
@ -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 `<div class="flows-river-container" id="river-mount"></div>`;
private renderAnalyticsPanel(): string {
return `
<div class="flows-analytics-panel ${this.analyticsOpen ? "open" : ""}" id="analytics-panel">
<div class="analytics-header">
<span class="analytics-title">Analytics</span>
<div class="analytics-tabs">
<button class="analytics-tab ${this.analyticsTab === "overview" ? "analytics-tab--active" : ""}" data-analytics-tab="overview">Overview</button>
<button class="analytics-tab ${this.analyticsTab === "transactions" ? "analytics-tab--active" : ""}" data-analytics-tab="transactions">Transactions</button>
</div>
<button class="analytics-close" data-analytics-close>&times;</button>
</div>
<div class="analytics-content">
${this.analyticsTab === "overview" ? this.renderTableTab() : this.renderTransactionsTab()}
</div>
</div>`;
}
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)