/**
 * <folk-flows-app> — main rFlows application component.
 *
 * Views:
 *   "landing"  — TBFF info hero + flow list cards
 *   "detail"   — Flow detail with tabs: Table | River | Transactions
 *
 * Attributes:
 *   space    — space slug
 *   flow-id  — if set, go straight to detail view
 *   mode     — "demo" to use hardcoded demo data (no API)
 */

import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types";
import { PORT_DEFS, deriveThresholds } from "../lib/types";
import { TourEngine } from "../../../shared/tour-engine";
import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
import { mapFlowToNodes } from "../lib/map-flow";
import { flowsSchema, flowsDocId, type FlowsDoc, type CanvasFlow } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { FlowsLocalFirstClient } from "../local-first-client";

interface FlowSummary {
	id: string;
	name: string;
	label?: string;
	status?: string;
	funnelCount?: number;
	outcomeCount?: number;
	totalValue?: number;
}

interface Transaction {
	id: string;
	type: string;
	amount: number;
	from?: string;
	to?: string;
	timestamp: string;
	description?: string;
}

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 {
	try {
		const raw = localStorage.getItem("encryptid_session");
		if (!raw) return null;
		const session = JSON.parse(raw);
		if (!session?.accessToken) return null;
		return session;
	} catch { return null; }
}

function isAuthenticated(): boolean { return getSession() !== null; }
function getAccessToken(): string | null { return getSession()?.accessToken ?? null; }
function getUsername(): string | null { return getSession()?.claims?.username ?? null; }

class FolkFlowsApp extends HTMLElement {
	private shadow: ShadowRoot;
	private space = "";
	private view: View = "landing";
	private flowId = "";
	private analyticsOpen = false;
	private analyticsTab: "overview" | "transactions" = "overview";
	private isDemo = false;

	private flows: FlowSummary[] = [];
	private nodes: FlowNode[] = [];
	private flowName = "";
	private transactions: Transaction[] = [];
	private txLoaded = false;

	private loading = false;
	private error = "";
	private _offlineUnsub: (() => void) | null = null;

	// Canvas state
	private canvasZoom = 1;
	private canvasPanX = 0;
	private canvasPanY = 0;
	private selectedNodeId: string | null = null;
	private draggingNodeId: string | null = null;
	private dragStartX = 0;
	private dragStartY = 0;
	private dragNodeStartX = 0;
	private dragNodeStartY = 0;
	private isPanning = false;
	private panStartX = 0;
	private panStartY = 0;
	private panStartPanX = 0;
	private panStartPanY = 0;
	private editingNodeId: string | null = null;
	private isSimulating = false;
	private simInterval: ReturnType<typeof setInterval> | null = null;
	private simSpeedMs = 100;
	private simTickCount = 0;
	private canvasInitialized = false;

	// Edge selection & drag state
	private selectedEdgeKey: string | null = null; // "fromId::toId::edgeType"
	private draggingEdgeKey: string | null = null;
	private edgeDragPointerId: number | null = null;

	// Source purchase modal state
	private sourceModalNodeId: string | null = null;

	// 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<string, NodeAnalyticsStats> = new Map();

	// Wiring state
	private wiringActive = false;
	private wiringSourceNodeId: string | null = null;
	private wiringSourcePortKind: PortKind | null = null;
	private wiringSourcePortSide: "left" | "right" | null = null;
	private wiringDragging = false;
	private wiringPointerX = 0;
	private wiringPointerY = 0;

	// Touch gesture state (two-finger pinch-to-zoom & pan)
	private isTouchPanning = false;
	private lastTouchCenter: { x: number; y: number } | null = null;
	private lastTouchDist: number | null = null;

	// Bound handlers for cleanup
	private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null;
	private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
	private _boundPointerUp: ((e: PointerEvent) => void) | null = null;
	private _dragRafId: number | null = null;

	// Flow storage & switching
	private localFirstClient: FlowsLocalFirstClient | null = null;
	private currentFlowId = "";
	private saveTimer: ReturnType<typeof setTimeout> | null = null;
	private flowDropdownOpen = false;
	private flowManagerOpen = false;
	private _lfcUnsub: (() => void) | null = null;

	// Tour engine
	private _tour!: TourEngine;
	private static readonly TOUR_STEPS = [
		{ target: '[data-canvas-action="add-source"]', title: "Add a Source", message: "Sources represent inflows of resources. Click the + Source button to add one.", advanceOnClick: true },
		{ target: '[data-canvas-action="add-funnel"]', title: "Add a Funnel", message: "Funnels allocate resources between spending and overflow. Click + Funnel to add one.", advanceOnClick: true },
		{ target: '[data-canvas-action="add-outcome"]', title: "Add an Outcome", message: "Outcomes are the goals your flow is working towards. Click + Outcome to add one.", advanceOnClick: true },
		{ target: '.flows-node', title: "Wire a Connection", message: "Drag from a port (the colored dots on nodes) to another node to create a flow connection. Click Next when ready.", advanceOnClick: false },
		{ target: '[data-canvas-action="sim"]', title: "Run Simulation", message: "Press Play to simulate resource flows through your system. Click Play to finish the tour!", advanceOnClick: true },
	];

	constructor() {
		super();
		this.shadow = this.attachShadow({ mode: "open" });
		this._tour = new TourEngine(
			this.shadow,
			FolkFlowsApp.TOUR_STEPS,
			"rflows_tour_done",
			() => this.shadow.getElementById("canvas-container"),
		);
	}

	connectedCallback() {
		this.space = this.getAttribute("space") || "demo";
		this.flowId = this.getAttribute("flow-id") || "";
		this.isDemo = this.getAttribute("mode") === "demo" || this.space === "demo";

		// Mirror document theme to host for shadow DOM CSS selectors
		this._syncTheme();
		document.addEventListener("theme-change", () => this._syncTheme());
		new MutationObserver(() => this._syncTheme())
			.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });

		// Canvas-first: always open in detail (canvas) view
		this.view = "detail";

		if (this.isDemo) {
			// Demo/anon: load from localStorage or demoNodes
			this.loadDemoOrLocalFlow();
		} else if (this.flowId) {
			// Direct link to a specific API flow
			this.loadFlow(this.flowId);
		} else {
			// Authenticated: init local-first client and load active flow
			this.initLocalFirstClient();
		}
	}

	private _syncTheme() {
		const theme = document.documentElement.getAttribute("data-theme") || "dark";
		this.setAttribute("data-theme", theme);
	}

	private loadDemoOrLocalFlow() {
		const activeId = localStorage.getItem('rflows:local:active') || '';
		if (activeId) {
			const raw = localStorage.getItem(`rflows:local:${activeId}`);
			if (raw) {
				try {
					const flow = JSON.parse(raw) as CanvasFlow;
					this.currentFlowId = flow.id;
					this.flowName = flow.name;
					this.nodes = flow.nodes.map((n: FlowNode) => ({ ...n, data: { ...n.data } }));
					this.restoreViewport(flow.id);
					this.render();
					return;
				} catch { /* fall through to demoNodes */ }
			}
		}
		// Fallback: demoNodes
		this.currentFlowId = 'demo';
		this.flowName = "TBFF Demo Flow";
		this.nodes = demoNodes.map((n) => ({ ...n, data: { ...n.data } }));
		localStorage.setItem('rflows:local:active', 'demo');
		this.render();
	}

	private async initLocalFirstClient() {
		this.loading = true;
		this.render();

		try {
			this.localFirstClient = new FlowsLocalFirstClient(this.space);
			await this.localFirstClient.init();
			await this.localFirstClient.subscribe();

			// Listen for remote changes
			this._lfcUnsub = this.localFirstClient.onChange((doc) => {
				if (!this.currentFlowId) return;
				const flow = doc.canvasFlows?.[this.currentFlowId];
				if (flow && !this.saveTimer) {
					// Only update if we're not in the middle of saving
					this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } })));
					this.drawCanvasContent();
				}
			});

			// Load active flow or first available or demoNodes
			const activeId = this.localFirstClient.getActiveFlowId();
			const flows = this.localFirstClient.listCanvasFlows();

			if (activeId && this.localFirstClient.getCanvasFlow(activeId)) {
				this.loadCanvasFlow(activeId);
			} else if (flows.length > 0) {
				this.loadCanvasFlow(flows[0].id);
			} else {
				// No flows yet — create one from demoNodes
				const newId = crypto.randomUUID();
				const now = Date.now();
				const username = getUsername();
				const newFlow: CanvasFlow = {
					id: newId,
					name: 'My First Flow',
					nodes: demoNodes.map((n) => ({ ...n, data: { ...n.data } })),
					createdAt: now,
					updatedAt: now,
					createdBy: username ? `did:encryptid:${username}` : null,
				};
				this.localFirstClient.saveCanvasFlow(newFlow);
				this.localFirstClient.setActiveFlow(newId);
				this.loadCanvasFlow(newId);
			}
		} catch (err) {
			// Offline or error — fall back to demoNodes
			console.warn('[FlowsApp] Local-first init failed, using demo nodes', err);
			this.loadDemoOrLocalFlow();
		}

		// Safety net: if no nodes were loaded (corrupted IDB, empty doc), show demo
		if (this.nodes.length === 0) {
			console.warn('[FlowsApp] No nodes after init, falling back to demo');
			this.loadDemoOrLocalFlow();
		}

		this.loading = false;
	}

	private loadCanvasFlow(flowId: string) {
		const flow = this.localFirstClient?.getCanvasFlow(flowId);
		if (!flow) return;
		this.currentFlowId = flow.id;
		this.flowName = flow.name;
		this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } })));
		this.localFirstClient?.setActiveFlow(flowId);
		this.restoreViewport(flowId);
		this.loading = false;
		this.canvasInitialized = false; // force re-fit on switch
		this.render();
	}

	disconnectedCallback() {
		this._offlineUnsub?.();
		this._offlineUnsub = null;
		this._lfcUnsub?.();
		this._lfcUnsub = null;
		if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; }
		this.localFirstClient?.disconnect();
	}

	// ─── Auto-save (debounced) ──────────────────────────────

	private scheduleSave() {
		if (this.saveTimer) clearTimeout(this.saveTimer);
		this.saveTimer = setTimeout(() => { this.executeSave(); this.saveTimer = null; }, 1500);
	}

	private executeSave() {
		if (this.localFirstClient && this.currentFlowId) {
			this.localFirstClient.updateFlowNodes(this.currentFlowId, this.nodes);
		} else if (this.currentFlowId) {
			// Anon/demo: save to localStorage
			const flow: CanvasFlow = {
				id: this.currentFlowId,
				name: this.flowName,
				nodes: this.nodes,
				createdAt: Date.now(),
				updatedAt: Date.now(),
				createdBy: null,
			};
			localStorage.setItem(`rflows:local:${this.currentFlowId}`, JSON.stringify(flow));
			// Maintain local flow list
			const listRaw = localStorage.getItem('rflows:local:list');
			const list: string[] = listRaw ? JSON.parse(listRaw) : [];
			if (!list.includes(this.currentFlowId)) {
				list.push(this.currentFlowId);
				localStorage.setItem('rflows:local:list', JSON.stringify(list));
			}
		}
	}

	// ─── Viewport persistence ───────────────────────────────

	private saveViewport() {
		if (!this.currentFlowId) return;
		localStorage.setItem(`rflows:viewport:${this.currentFlowId}`, JSON.stringify({
			zoom: this.canvasZoom, panX: this.canvasPanX, panY: this.canvasPanY,
		}));
	}

	private restoreViewport(flowId: string) {
		const raw = localStorage.getItem(`rflows:viewport:${flowId}`);
		if (raw) {
			try {
				const { zoom, panX, panY } = JSON.parse(raw);
				this.canvasZoom = zoom;
				this.canvasPanX = panX;
				this.canvasPanY = panY;
			} catch { /* ignore corrupt data */ }
		}
	}

	// ─── Flow switching ─────────────────────────────────────

	private switchToFlow(flowId: string) {
		// Save current flow if dirty
		if (this.saveTimer) {
			clearTimeout(this.saveTimer);
			this.saveTimer = null;
			this.executeSave();
		}
		this.saveViewport();

		// Stop simulation if running
		if (this.isSimulating) this.toggleSimulation();
		// Exit inline edit
		if (this.inlineEditNodeId) this.exitInlineEdit();

		if (this.localFirstClient) {
			this.loadCanvasFlow(flowId);
		} else {
			// Local/demo mode
			const raw = localStorage.getItem(`rflows:local:${flowId}`);
			if (raw) {
				try {
					const flow = JSON.parse(raw) as CanvasFlow;
					this.currentFlowId = flow.id;
					this.flowName = flow.name;
					this.nodes = flow.nodes.map((n: FlowNode) => ({ ...n, data: { ...n.data } }));
				} catch { return; }
			} else if (flowId === 'demo') {
				this.currentFlowId = 'demo';
				this.flowName = 'TBFF Demo Flow';
				this.nodes = demoNodes.map((n) => ({ ...n, data: { ...n.data } }));
			} else { return; }
			localStorage.setItem('rflows:local:active', flowId);
			this.restoreViewport(flowId);
			this.canvasInitialized = false;
			this.render();
		}
	}

	private createNewFlow() {
		const id = crypto.randomUUID();
		const now = Date.now();
		const newFlow: CanvasFlow = {
			id,
			name: 'Untitled Flow',
			nodes: [{
				id: `source-${Date.now().toString(36)}`,
				type: 'source' as const,
				position: { x: 400, y: 200 },
				data: { label: 'New Source', flowRate: 1000, sourceType: 'card', targetAllocations: [] },
			}],
			createdAt: now,
			updatedAt: now,
			createdBy: getUsername() ? `did:encryptid:${getUsername()}` : null,
		};

		if (this.localFirstClient) {
			this.localFirstClient.saveCanvasFlow(newFlow);
			this.localFirstClient.setActiveFlow(id);
			this.loadCanvasFlow(id);
		} else {
			localStorage.setItem(`rflows:local:${id}`, JSON.stringify(newFlow));
			const listRaw = localStorage.getItem('rflows:local:list');
			const list: string[] = listRaw ? JSON.parse(listRaw) : [];
			list.push(id);
			localStorage.setItem('rflows:local:list', JSON.stringify(list));
			localStorage.setItem('rflows:local:active', id);
			this.switchToFlow(id);
		}
	}

	private getFlowList(): { id: string; name: string; nodeCount: number; updatedAt: number }[] {
		if (this.localFirstClient) {
			return this.localFirstClient.listCanvasFlows().map(f => ({
				id: f.id, name: f.name, nodeCount: f.nodes?.length || 0, updatedAt: f.updatedAt,
			}));
		}
		// Local mode
		const listRaw = localStorage.getItem('rflows:local:list');
		const list: string[] = listRaw ? JSON.parse(listRaw) : [];
		// Always include demo if not already tracked
		if (!list.includes('demo')) list.unshift('demo');
		return list.map(id => {
			if (id === 'demo') return { id: 'demo', name: 'TBFF Demo Flow', nodeCount: demoNodes.length, updatedAt: 0 };
			const raw = localStorage.getItem(`rflows:local:${id}`);
			if (!raw) return null;
			try {
				const f = JSON.parse(raw) as CanvasFlow;
				return { id: f.id, name: f.name, nodeCount: f.nodes?.length || 0, updatedAt: f.updatedAt };
			} catch { return null; }
		}).filter(Boolean) as any[];
	}

	private async subscribeOffline() {
		const runtime = (window as any).__rspaceOfflineRuntime;
		if (!runtime?.isInitialized) return;

		try {
			const docId = flowsDocId(this.space) as DocumentId;
			const doc = await runtime.subscribe(docId, flowsSchema);

			// Render cached flow associations immediately
			this.renderFlowsFromDoc(doc);

			// Listen for remote changes
			this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
				this.renderFlowsFromDoc(updated);
			});
		} catch {
			// Offline runtime unavailable — REST fallback already running
		}
	}

	private renderFlowsFromDoc(doc: FlowsDoc) {
		if (!doc?.spaceFlows) return;
		const entries = Object.values(doc.spaceFlows);
		if (entries.length === 0 && this.flows.length > 0) return; // Don't clobber REST data with empty doc
		// Merge Automerge flow associations as summaries
		const fromDoc: FlowSummary[] = entries.map(sf => ({
			id: sf.flowId,
			name: sf.flowId,
			status: 'active',
		}));
		if (fromDoc.length > 0 && this.flows.length === 0) {
			this.flows = fromDoc;
			this.render();
		}
	}

	private getApiBase(): string {
		const path = window.location.pathname;
		// Subdomain: /rflows/...  or  Direct: /{space}/rflows/...
		const match = path.match(/^(\/[^/]+)?\/rflows/);
		return match ? `${match[0]}` : "";
	}

	private async loadFlows() {
		this.loading = true;
		this.render();
		try {
			const base = this.getApiBase();
			const params = this.space ? `?space=${encodeURIComponent(this.space)}` : "";
			const res = await fetch(`${base}/api/flows${params}`);
			if (res.ok) {
				const data = await res.json();
				this.flows = Array.isArray(data) ? data : (data.flows || []);
			}
		} catch {
			// Flow service unavailable — landing page still works with demo link
		}
		this.loading = false;
		this.render();
	}

	private async loadFlow(flowId: string) {
		this.loading = true;
		this.render();
		try {
			const base = this.getApiBase();
			const res = await fetch(`${base}/api/flows/${encodeURIComponent(flowId)}`);
			if (res.ok) {
				const data = await res.json();
				this.nodes = mapFlowToNodes(data);
				this.flowName = data.name || data.label || flowId;
			} else {
				this.error = `Flow not found (${res.status})`;
			}
		} catch {
			this.error = "Failed to load flow";
		}
		this.loading = false;
		this.render();
	}

	private async loadTransactions() {
		if (this.txLoaded || this.isDemo) return;
		this.loading = true;
		this.render();
		try {
			const base = this.getApiBase();
			const res = await fetch(`${base}/api/flows/${encodeURIComponent(this.flowId)}/transactions`);
			if (res.ok) {
				const data = await res.json();
				this.transactions = Array.isArray(data) ? data : (data.transactions || []);
			}
		} catch {
			// Transactions unavailable
		}
		this.txLoaded = true;
		this.loading = false;
		this.render();
	}

	private getCssPath(): string {
		// In rSpace: /modules/rflows/flows.css  |  Standalone: /modules/rflows/flows.css
		// The shell always serves from /modules/rflows/ in both modes
		return "/modules/rflows/flows.css";
	}

	private render() {
		this.shadow.innerHTML = `
		<style>
			:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
			*, *::before, *::after { box-sizing: border-box; }
		</style>
		<link rel="stylesheet" href="${this.getCssPath()}">
		${this.error ? `<div class="flows-error">${this.esc(this.error)}</div>` : ""}
		${this.loading && this.view === "landing" ? '<div class="flows-loading">Loading...</div>' : ""}
		${this.renderView()}
		`;
		this.attachListeners();
	}

	private renderView(): string {
		if (this.view === "detail") return this.renderDetail();
		return this.renderLanding();
	}

	// ─── Landing page ──────────────────────────────────────

	private renderLanding(): string {
		const demoUrl = this.getApiBase() ? `${this.getApiBase()}/demo` : "/rflows/demo";
		const authed = isAuthenticated();
		const username = getUsername();

		return `
		<div class="flows-landing">
			<div class="rapp-nav">
				<span class="rapp-nav__title">Flows</span>
				<div class="rapp-nav__actions">
					<a href="${this.esc(demoUrl)}" class="rapp-nav__btn rapp-nav__btn--secondary">Demo</a>
					${authed
						? `<button class="rapp-nav__btn" data-action="create-flow">+ Create Flow</button>`
						: `<span style="font-size:12px;color:var(--rs-text-muted)">Sign in to create flows</span>`
					}
				</div>
			</div>

			<div class="flows-desc" style="color:var(--rs-text-secondary);font-size:14px;line-height:1.6;max-width:600px;margin-bottom:24px">
				Design transparent resource flows with sufficiency-based cascading.
				Funnels fill to their threshold, then overflow routes surplus to the next layer &mdash;
				ensuring every level has <em style="color:var(--rs-warning);font-style:normal;font-weight:600">enough</em> before abundance cascades forward.
			</div>

			<div class="flows-features">
				<div class="flows-features__grid">
					<div class="flows-features__card">
						<div class="flows-features__icon">&#x1F4B0;</div>
						<h3>Sources</h3>
						<p>Revenue streams split across funnels by configurable allocation percentages.</p>
					</div>
					<div class="flows-features__card">
						<div class="flows-features__icon">&#x1F3DB;</div>
						<h3>Funnels</h3>
						<p>Budget buckets with min/max thresholds and sufficiency-based overflow cascading.</p>
					</div>
					<div class="flows-features__card">
						<div class="flows-features__icon">&#x1F3AF;</div>
						<h3>Outcomes</h3>
						<p>Funding targets that receive spending allocations. Track progress toward each goal.</p>
					</div>
					<div class="flows-features__card">
						<div class="flows-features__icon">&#x1F30A;</div>
						<h3>River View</h3>
						<p>Animated sankey diagram showing live fund flows through your entire system.</p>
					</div>
					<div class="flows-features__card">
						<div class="flows-features__icon">&#x2728;</div>
						<h3>Enoughness</h3>
						<p>System-wide sufficiency scoring. Golden glow when funnels reach their threshold.</p>
					</div>
				</div>
			</div>

			<div class="flows-flows">
				<div class="flows-flows__header">
					<h2 class="flows-flows__heading">${authed ? `Flows in ${this.esc(this.space)}` : "Your Flows"}</h2>
					${authed ? `<span class="flows-flows__user">Signed in as ${this.esc(username || "")}</span>` : ""}
				</div>
				${this.flows.length > 0 ? `
					<div class="flows-flows__grid">
						${this.flows.map((f) => this.renderFlowCard(f)).join("")}
					</div>
				` : `
					<div class="flows-flows__empty">
						${authed
							? `<p>No flows in this space yet.</p>
							   <p><a href="${this.esc(demoUrl)}">Explore the demo</a> or create your first flow.</p>`
							: `<p>Sign in to see your space&rsquo;s flows, or <a href="${this.esc(demoUrl)}">explore the demo</a>.</p>`
						}
					</div>
				`}
			</div>

			<div class="flows-about">
				<h2 class="flows-about__heading">How TBFF Works</h2>
				<div class="flows-about__steps">
					<div class="flows-about__step">
						<div class="flows-about__step-num">1</div>
						<div>
							<h3>Define Sources</h3>
							<p>Add revenue streams &mdash; grants, donations, sales, or any recurring income &mdash; with allocation splits.</p>
						</div>
					</div>
					<div class="flows-about__step">
						<div class="flows-about__step-num">2</div>
						<div>
							<h3>Configure Funnels</h3>
							<p>Set minimum, sufficient, and maximum thresholds. Overflow rules determine where surplus flows.</p>
						</div>
					</div>
					<div class="flows-about__step">
						<div class="flows-about__step-num">3</div>
						<div>
							<h3>Track Outcomes</h3>
							<p>Funding targets receive allocations as funnels reach sufficiency. Watch the river flow in real time.</p>
						</div>
					</div>
				</div>
			</div>
		</div>`;
	}

	private renderFlowCard(f: FlowSummary): string {
		const detailUrl = this.getApiBase()
			? `${this.getApiBase()}/flow/${encodeURIComponent(f.id)}`
			: `/rflows/flow/${encodeURIComponent(f.id)}`;
		const value = f.totalValue != null ? `$${Math.floor(f.totalValue).toLocaleString()}` : "";

		return `
		<a href="${this.esc(detailUrl)}" class="flows-flow-card" data-flow="${this.esc(f.id)}">
			<div class="flows-flow-card__name">${this.esc(f.name || f.label || f.id)}</div>
			${value ? `<div class="flows-flow-card__value">${value}</div>` : ""}
			<div class="flows-flow-card__meta">
				${f.funnelCount != null ? `${f.funnelCount} funnels` : ""}
				${f.outcomeCount != null ? ` &middot; ${f.outcomeCount} outcomes` : ""}
				${f.status ? ` &middot; ${f.status}` : ""}
			</div>
		</a>`;
	}

	// ─── Detail view with tabs ─────────────────────────────

	private renderDetail(): string {
		if (this.loading) {
			return '<div class="flows-loading">Loading...</div>';
		}

		return `
		<div class="flows-detail flows-detail--fullpage">
			${this.renderDiagramTab()}
		</div>`;
	}

	// ─── Table tab ────────────────────────────────────────

	private renderTableTab(): string {
		const funnels = this.nodes.filter((n) => n.type === "funnel");
		const outcomes = this.nodes.filter((n) => n.type === "outcome");
		const sources = this.nodes.filter((n) => n.type === "source");

		return `
		<div class="flows-table">
			${sources.length > 0 ? `
				<div class="flows-section">
					<h3 class="flows-section__title">Sources</h3>
					<div class="flows-cards">
						${sources.map((n) => this.renderSourceCard(n.data as SourceNodeData, n.id)).join("")}
					</div>
				</div>
			` : ""}

			<div class="flows-section">
				<h3 class="flows-section__title">Funnels</h3>
				<div class="flows-cards">
					${funnels.map((n) => this.renderFunnelCard(n.data as FunnelNodeData, n.id)).join("")}
				</div>
			</div>

			<div class="flows-section">
				<h3 class="flows-section__title">Outcomes</h3>
				<div class="flows-cards">
					${outcomes.map((n) => this.renderOutcomeCard(n.data as OutcomeNodeData, n.id)).join("")}
				</div>
			</div>
		</div>`;
	}

	private renderSourceCard(data: SourceNodeData, id: string): string {
		const allocations = data.targetAllocations || [];
		return `
		<div class="flows-card">
			<div class="flows-card__header">
				<span class="flows-card__icon">&#x1F4B0;</span>
				<span class="flows-card__label">${this.esc(data.label)}</span>
				<span class="flows-card__type">${data.sourceType}</span>
			</div>
			<div class="flows-card__stat">
				<span class="flows-card__stat-value">$${data.flowRate.toLocaleString()}</span>
				<span class="flows-card__stat-label">/month</span>
			</div>
			${allocations.length > 0 ? `
				<div class="flows-card__allocs">
					${allocations.map((a) => `
						<div class="flows-card__alloc">
							<span class="flows-card__alloc-dot" style="background:${a.color}"></span>
							${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
						</div>
					`).join("")}
				</div>
			` : ""}
		</div>`;
	}

	private renderFunnelCard(data: FunnelNodeData, id: string): string {
		const sufficiency = computeSufficiencyState(data);
		const threshold = data.sufficientThreshold ?? data.maxThreshold;
		const fillPct = Math.min(100, (data.currentValue / (data.maxCapacity || 1)) * 100);
		const suffPct = Math.min(100, (data.currentValue / (threshold || 1)) * 100);

		const statusClass = sufficiency === "abundant" ? "flows-status--abundant"
			: sufficiency === "sufficient" ? "flows-status--sufficient"
			: data.currentValue < data.minThreshold ? "flows-status--critical"
			: "flows-status--seeking";

		const statusLabel = sufficiency === "abundant" ? "Abundant"
			: sufficiency === "sufficient" ? "Sufficient"
			: data.currentValue < data.minThreshold ? "Critical"
			: "Seeking";

		return `
		<div class="flows-card">
			<div class="flows-card__header">
				<span class="flows-card__icon">&#x1F3DB;</span>
				<span class="flows-card__label">${this.esc(data.label)}</span>
				<span class="flows-card__status ${statusClass}">${statusLabel}</span>
			</div>
			<div class="flows-card__bar-container">
				<div class="flows-card__bar" style="width:${fillPct}%"></div>
				<div class="flows-card__bar-threshold" style="left:${Math.min(100, (threshold / (data.maxCapacity || 1)) * 100)}%"></div>
			</div>
			<div class="flows-card__stats">
				<div>
					<span class="flows-card__stat-value">$${Math.floor(data.currentValue).toLocaleString()}</span>
					<span class="flows-card__stat-label">/ $${Math.floor(threshold).toLocaleString()}</span>
				</div>
				<div>
					<span class="flows-card__stat-value">${Math.round(suffPct)}%</span>
					<span class="flows-card__stat-label">sufficiency</span>
				</div>
			</div>
			<div class="flows-card__thresholds">
				<span>Min: $${Math.floor(data.minThreshold).toLocaleString()}</span>
				<span>Max: $${Math.floor(data.maxThreshold).toLocaleString()}</span>
				<span>Cap: $${Math.floor(data.maxCapacity).toLocaleString()}</span>
			</div>
			${data.overflowAllocations.length > 0 ? `
				<div class="flows-card__allocs">
					<div class="flows-card__alloc-title">Overflow</div>
					${data.overflowAllocations.map((a) => `
						<div class="flows-card__alloc">
							<span class="flows-card__alloc-dot" style="background:${a.color}"></span>
							${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
						</div>
					`).join("")}
				</div>
			` : ""}
			${data.spendingAllocations.length > 0 ? `
				<div class="flows-card__allocs">
					<div class="flows-card__alloc-title">Spending</div>
					${data.spendingAllocations.map((a) => `
						<div class="flows-card__alloc">
							<span class="flows-card__alloc-dot" style="background:${a.color}"></span>
							${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
						</div>
					`).join("")}
				</div>
			` : ""}
		</div>`;
	}

	private renderOutcomeCard(data: OutcomeNodeData, id: string): string {
		const fillPct = data.fundingTarget > 0
			? Math.min(100, (data.fundingReceived / data.fundingTarget) * 100)
			: 0;
		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 `
		<div class="flows-card">
			<div class="flows-card__header">
				<span class="flows-card__icon">&#x1F3AF;</span>
				<span class="flows-card__label">${this.esc(data.label)}</span>
				<span class="flows-card__status" style="color:${statusColor}">${data.status}</span>
			</div>
			${data.description ? `<div class="flows-card__desc">${this.esc(data.description)}</div>` : ""}
			<div class="flows-card__bar-container">
				<div class="flows-card__bar flows-card__bar--outcome" style="width:${fillPct}%;background:${statusColor}"></div>
			</div>
			<div class="flows-card__stats">
				<div>
					<span class="flows-card__stat-value">$${Math.floor(data.fundingReceived).toLocaleString()}</span>
					<span class="flows-card__stat-label">/ $${Math.floor(data.fundingTarget).toLocaleString()}</span>
				</div>
				<div>
					<span class="flows-card__stat-value">${Math.round(fillPct)}%</span>
					<span class="flows-card__stat-label">funded</span>
				</div>
			</div>
		</div>`;
	}

	private getNodeLabel(id: string): string {
		const node = this.nodes.find((n) => n.id === id);
		if (!node) return id;
		return (node.data as any).label || id;
	}

	// ─── Diagram tab (interactive canvas) ─────────────────

	private renderDiagramTab(): string {
		if (this.nodes.length === 0) {
			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 ? "var(--rflows-score-gold)" : scorePct >= 60 ? "var(--rflows-score-green)" : scorePct >= 30 ? "var(--rflows-score-amber)" : "var(--rflows-score-red)";

		return `
		<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-toolbar">
				<div class="flows-dropdown" id="flow-dropdown">
					<button class="flows-toolbar-btn flows-dropdown__trigger" data-canvas-action="flow-picker">
						<span class="flows-dropdown__name">${this.esc(this.flowName || 'Untitled')}</span>
						<span class="flows-dropdown__chevron">&#x25BE;</span>
					</button>
					<div class="flows-dropdown__menu" id="flow-dropdown-menu" style="display:none">
						${this.renderFlowDropdownItems()}
						<div class="flows-dropdown__sep"></div>
						<button class="flows-dropdown__item flows-dropdown__item--new" data-flow-action="new-flow">+ New Flow</button>
						<button class="flows-dropdown__item" data-flow-action="manage-flows">Manage Flows...</button>
					</div>
				</div>
				<div class="flows-toolbar-sep"></div>
				<button class="flows-toolbar-btn flows-toolbar-btn--fund" data-canvas-action="quick-fund">💰 Fund</button>
				<div class="flows-toolbar-sep"></div>
				<button class="flows-toolbar-btn flows-toolbar-btn--source" data-canvas-action="add-source">+ Source</button>
				<button class="flows-toolbar-btn flows-toolbar-btn--funnel" data-canvas-action="add-funnel">+ Funnel</button>
				<button class="flows-toolbar-btn flows-toolbar-btn--outcome" data-canvas-action="add-outcome">+ Outcome</button>
				<div class="flows-toolbar-sep"></div>
				<button class="flows-toolbar-btn" data-canvas-action="sim" id="sim-btn">${this.isSimulating ? "⏸ Pause" : "▶ Play"}</button>
				<button class="flows-toolbar-btn ${this.analyticsOpen ? "flows-toolbar-btn--active" : ""}" data-canvas-action="analytics">📊 Analytics</button>
				<button class="flows-toolbar-btn" data-canvas-action="share">🔗 Share</button>
				<button class="flows-toolbar-btn" data-canvas-action="tour">🎓 Tour</button>
			</div>
			<svg class="flows-canvas-svg" id="flow-canvas">
				<defs>
					<marker id="arrowhead-inflow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
						<path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--rflows-edge-inflow)"/>
					</marker>
					<marker id="arrowhead-spending" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
						<path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--rflows-edge-spending)"/>
					</marker>
					<marker id="arrowhead-overflow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
						<path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--rflows-edge-overflow)"/>
					</marker>
					<linearGradient id="faucet-pipe-grad" x1="0" y1="0" x2="0" y2="1">
						<stop offset="0%" stop-color="#b0b8c4"/>
						<stop offset="40%" stop-color="#8892a0"/>
						<stop offset="100%" stop-color="#626d7d"/>
					</linearGradient>
				</defs>
				<g id="canvas-transform">
					<g id="edge-layer"></g>
					<g id="wire-layer"></g>
					<g id="node-layer"></g>
				</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:var(--rflows-edge-inflow)"></span>Inflow</span>
				<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:var(--rflows-edge-spending)"></span>Spending</span>
				<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:var(--rflows-edge-overflow)"></span>Overflow</span>
				<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:var(--rflows-status-critical);border-radius:50%"></span>Critical</span>
				<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:var(--rflows-status-sustained);border-radius:50%"></span>Sustained</span>
				<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:var(--rflows-status-thriving);border-radius:50%"></span>Thriving</span>
			</div>
			<div class="flows-canvas-zoom">
				<button class="flows-zoom-btn" data-canvas-action="zoom-in" title="Zoom in">
					<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
				</button>
				<span class="flows-zoom-level" id="zoom-level">${Math.round(this.canvasZoom * 100)}%</span>
				<button class="flows-zoom-btn" data-canvas-action="zoom-out" title="Zoom out">
					<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
				</button>
				<div class="flows-zoom-sep"></div>
				<button class="flows-zoom-btn" data-canvas-action="fit" title="Fit to view">
					<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 6V3a1 1 0 011-1h3M10 2h3a1 1 0 011 1v3M14 10v3a1 1 0 01-1 1h-3M6 14H3a1 1 0 01-1-1v-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
				</button>
			</div>
			<div class="flows-sim-speed" id="sim-speed-container" style="display:${this.isSimulating ? "flex" : "none"}">
				<input type="range" class="flows-speed-slider" id="sim-speed-slider" min="20" max="1000" value="${this.simSpeedMs}" step="10"/>
				<span class="flows-speed-label" id="sim-speed-label">${this.simSpeedMs}ms</span>
			</div>
			<div class="flows-timeline" id="sim-timeline" style="display:${this.isSimulating ? "flex" : "none"}">
				<div class="flows-timeline__track">
					<div class="flows-timeline__fill" id="timeline-fill" style="width:0%"></div>
				</div>
				<span class="flows-timeline__tick" id="timeline-tick">Tick 0</span>
			</div>
			<div class="flows-node-tooltip" id="node-tooltip" style="display:none"></div>
		</div>
		${this.flowManagerOpen ? this.renderFlowManagerModal() : ''}`;
	}

	private renderFlowDropdownItems(): string {
		const flows = this.getFlowList();
		if (flows.length === 0) return '<div class="flows-dropdown__item" style="color:var(--rs-text-muted);pointer-events:none">No flows</div>';
		return flows.map(f =>
			`<button class="flows-dropdown__item ${f.id === this.currentFlowId ? 'flows-dropdown__item--active' : ''}" data-flow-switch="${this.esc(f.id)}">${this.esc(f.name)}</button>`
		).join('');
	}

	private renderFlowManagerModal(): string {
		const flows = this.getFlowList();
		return `
		<div class="flows-mgmt-overlay" id="flow-manager-overlay">
			<div class="flows-mgmt-modal">
				<div class="flows-mgmt__header">
					<h2>Manage Flows</h2>
					<button class="flows-mgmt__close" data-mgmt-action="close">&times;</button>
				</div>
				<div class="flows-mgmt__body">
					${flows.length === 0
						? '<div class="flows-mgmt__empty">No flows yet. Create one to get started.</div>'
						: flows.map(f => `
						<div class="flows-mgmt__row" data-mgmt-flow="${this.esc(f.id)}">
							<div class="flows-mgmt__row-name">${this.esc(f.name)}</div>
							<div class="flows-mgmt__row-meta">${f.nodeCount} nodes${f.updatedAt ? ' &middot; ' + new Date(f.updatedAt).toLocaleDateString() : ''}</div>
							<div class="flows-mgmt__row-actions">
								<button class="flows-mgmt__row-btn" data-mgmt-action="rename" data-mgmt-id="${this.esc(f.id)}" title="Rename">&#x270E;</button>
								<button class="flows-mgmt__row-btn" data-mgmt-action="duplicate" data-mgmt-id="${this.esc(f.id)}" title="Duplicate">&#x2398;</button>
								<button class="flows-mgmt__row-btn" data-mgmt-action="export" data-mgmt-id="${this.esc(f.id)}" title="Export JSON">&#x2B07;</button>
								<button class="flows-mgmt__row-btn flows-mgmt__row-btn--danger" data-mgmt-action="delete" data-mgmt-id="${this.esc(f.id)}" title="Delete">&#x2715;</button>
							</div>
						</div>
					`).join('')}
				</div>
				<div class="flows-mgmt__footer">
					<button data-mgmt-action="import">Import JSON</button>
					<button class="primary" data-mgmt-action="new">+ New Flow</button>
				</div>
			</div>
		</div>`;
	}

	// ─── Canvas lifecycle ─────────────────────────────────

	private initCanvas() {
		this.drawCanvasContent();
		this.updateCanvasTransform();
		this.attachCanvasListeners();
		if (!this.canvasInitialized) {
			this.canvasInitialized = true;
			requestAnimationFrame(() => this.fitView());
			// Auto-start tour on first visit
			if (!localStorage.getItem("rflows_tour_done")) {
				setTimeout(() => this.startTour(), 1200);
			}
		}
		this.loadFromHash();
	}

	private drawCanvasContent() {
		const edgeLayer = this.shadow.getElementById("edge-layer");
		const nodeLayer = this.shadow.getElementById("node-layer");
		if (!edgeLayer || !nodeLayer) return;
		edgeLayer.innerHTML = this.renderAllEdges();
		nodeLayer.innerHTML = this.renderAllNodes();
	}

	private updateCanvasTransform() {
		const g = this.shadow.getElementById("canvas-transform");
		if (g) g.setAttribute("transform", `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`);
		this.saveViewport();
		const zl = this.shadow.getElementById("zoom-level");
		if (zl) zl.textContent = `${Math.round(this.canvasZoom * 100)}%`;
	}

	private fitView() {
		const svg = this.shadow.getElementById("flow-canvas") as SVGSVGElement | null;
		if (!svg || this.nodes.length === 0) return;
		const rect = svg.getBoundingClientRect();
		if (rect.width === 0 || rect.height === 0) return;

		let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
		for (const n of this.nodes) {
			const s = this.getNodeSize(n);
			minX = Math.min(minX, n.position.x);
			minY = Math.min(minY, n.position.y);
			maxX = Math.max(maxX, n.position.x + s.w);
			maxY = Math.max(maxY, n.position.y + s.h);
		}
		const pad = 60;
		const contentW = maxX - minX + pad * 2;
		const contentH = maxY - minY + pad * 2;
		const scaleX = rect.width / contentW;
		const scaleY = rect.height / contentH;
		this.canvasZoom = Math.min(scaleX, scaleY, 1.5);
		this.canvasPanX = (rect.width - contentW * this.canvasZoom) / 2 - (minX - pad) * this.canvasZoom;
		this.canvasPanY = (rect.height - contentH * this.canvasZoom) / 2 - (minY - pad) * this.canvasZoom;
		this.updateCanvasTransform();
	}

	private getNodeSize(n: FlowNode): { w: number; h: number } {
		if (n.type === "source") {
			return { w: 200, h: 160 };
		}
		if (n.type === "funnel") {
			const d = n.data as FunnelNodeData;
			const baseW = 280;
			const cap = d.maxCapacity || 9000;
			const h = Math.round(200 + Math.min(200, (cap / 50000) * 200));
			return { w: baseW, h: Math.max(200, h) };
		}
		return { w: 220, h: 180 }; // outcome card
	}

	// ─── Canvas event wiring ──────────────────────────────

	private attachCanvasListeners() {
		const svg = this.shadow.getElementById("flow-canvas");
		if (!svg) return;

		// Wheel: pan (default) or zoom (Ctrl/pinch)
		// Trackpad two-finger scroll → pan; trackpad pinch / Ctrl+scroll → zoom
		svg.addEventListener("wheel", (e: WheelEvent) => {
			e.preventDefault();
			if (e.ctrlKey || e.metaKey) {
				// Zoom — ctrlKey is set by trackpad pinch gestures and Ctrl+scroll
				const zoomFactor = 1 - e.deltaY * 0.003;
				const rect = svg.getBoundingClientRect();
				const mx = e.clientX - rect.left;
				const my = e.clientY - rect.top;
				const newZoom = Math.max(0.1, Math.min(4, this.canvasZoom * zoomFactor));
				// Zoom toward pointer
				this.canvasPanX = mx - (mx - this.canvasPanX) * (newZoom / this.canvasZoom);
				this.canvasPanY = my - (my - this.canvasPanY) * (newZoom / this.canvasZoom);
				this.canvasZoom = newZoom;
			} else {
				// Pan — two-finger trackpad scroll or mouse wheel
				this.canvasPanX -= e.deltaX;
				this.canvasPanY -= e.deltaY;
			}
			this.updateCanvasTransform();
		}, { passive: false });

		// Delegated funnel valve + height drag handles
		svg.addEventListener("pointerdown", (e: PointerEvent) => {
			const target = e.target as Element;
			const valveG = target.closest(".funnel-valve-handle") as SVGGElement | null;
			const heightG = target.closest(".funnel-height-handle") as SVGGElement | null;
			const handleG = valveG || heightG;
			if (!handleG) return;
			e.stopPropagation();
			e.preventDefault();
			const nodeId = handleG.getAttribute("data-node-id");
			const node = this.nodes.find((n) => n.id === nodeId);
			if (!node || node.type !== "funnel") return;
			const fd = node.data as FunnelNodeData;
			const s = this.getNodeSize(node);
			const startX = e.clientX;
			const startY = e.clientY;

			if (valveG) {
				const startOutflow = fd.desiredOutflow || 0;
				handleG.setPointerCapture(e.pointerId);
				const label = handleG.querySelector("text");
				const onMove = (ev: PointerEvent) => {
					const deltaX = (ev.clientX - startX) / this.canvasZoom;
					let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 10000) / 50) * 50;
					newOutflow = Math.max(0, Math.min(10000, newOutflow));
					fd.desiredOutflow = newOutflow;
					fd.minThreshold = newOutflow;
					fd.maxThreshold = newOutflow * 6;
					if (fd.maxCapacity < fd.maxThreshold * 1.5) {
						fd.maxCapacity = Math.round(fd.maxThreshold * 1.5);
					}
					if (label) label.textContent = `◁ ${this.formatDollar(newOutflow)}/mo ▷`;
				};
				const onUp = () => {
					handleG.removeEventListener("pointermove", onMove as EventListener);
					handleG.removeEventListener("pointerup", onUp);
					handleG.removeEventListener("lostpointercapture", onUp);
					this.drawCanvasContent();
					this.redrawEdges();
					this.scheduleSave();
				};
				handleG.addEventListener("pointermove", onMove as EventListener);
				handleG.addEventListener("pointerup", onUp);
				handleG.addEventListener("lostpointercapture", onUp);
			} else {
				const startCapacity = fd.maxCapacity || 9000;
				handleG.setPointerCapture(e.pointerId);
				const label = handleG.querySelector("text");
				const onMove = (ev: PointerEvent) => {
					const deltaY = (ev.clientY - startY) / this.canvasZoom;
					let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500;
					newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(100000, newCapacity));
					fd.maxCapacity = newCapacity;
					if (label) label.textContent = `⇕ ${this.formatDollar(newCapacity)}`;
				};
				const onUp = () => {
					handleG.removeEventListener("pointermove", onMove as EventListener);
					handleG.removeEventListener("pointerup", onUp);
					handleG.removeEventListener("lostpointercapture", onUp);
					this.drawCanvasContent();
					this.redrawEdges();
					this.scheduleSave();
				};
				handleG.addEventListener("pointermove", onMove as EventListener);
				handleG.addEventListener("pointerup", onUp);
				handleG.addEventListener("lostpointercapture", onUp);
			}
		}, { capture: true });

		// Panning — pointerdown on SVG background
		svg.addEventListener("pointerdown", (e: PointerEvent) => {
			const target = e.target as Element;
			// Only pan when clicking SVG background (not on a node)
			if (target.closest(".flow-node")) return;
			if (target.closest(".edge-ctrl-group")) return;

			// Cancel wiring on empty canvas click
			if (this.wiringActive) { this.cancelWiring(); return; }

			this.isPanning = true;
			this.panStartX = e.clientX;
			this.panStartY = e.clientY;
			this.panStartPanX = this.canvasPanX;
			this.panStartPanY = this.canvasPanY;
			svg.classList.add("panning");
			svg.setPointerCapture(e.pointerId);

			// Deselect node and edge
			if (!target.closest(".flow-node") && !target.closest(".edge-group")) {
				this.selectedNodeId = null;
				this.selectedEdgeKey = null;
				this.updateSelectionHighlight();
			}
		});

		// Global pointer move/up (for both panning and node drag)
		let nodeDragStarted = false;
		const DRAG_THRESHOLD = 5;

		this._boundPointerMove = (e: PointerEvent) => {
			if (this.wiringActive && this.wiringDragging) {
				this.wiringPointerX = e.clientX;
				this.wiringPointerY = e.clientY;
				this.updateWiringTempLine();
				return;
			}
			// Edge drag — convert pointer to canvas coords and update waypoint
			if (this.draggingEdgeKey) {
				const rect = svg.getBoundingClientRect();
				const canvasX = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom;
				const canvasY = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom;
				this.setEdgeWaypoint(this.draggingEdgeKey, canvasX, canvasY);
				this.redrawEdges();
				return;
			}
			if (this.isPanning) {
				this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX);
				this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY);
				this.updateCanvasTransform();
				return;
			}
			if (this.draggingNodeId) {
				const rawDx = e.clientX - this.dragStartX;
				const rawDy = e.clientY - this.dragStartY;
				// Only start visual drag after exceeding threshold
				if (!nodeDragStarted) {
					if (Math.abs(rawDx) < DRAG_THRESHOLD && Math.abs(rawDy) < DRAG_THRESHOLD) return;
					nodeDragStarted = true;
					svg.classList.add("dragging");
				}
				// rAF throttle: skip if a frame is already queued
				if (this._dragRafId) return;
				this._dragRafId = requestAnimationFrame(() => {
					this._dragRafId = null;
					const dx = (e.clientX - this.dragStartX) / this.canvasZoom;
					const dy = (e.clientY - this.dragStartY) / this.canvasZoom;
					const node = this.nodes.find((n) => n.id === this.draggingNodeId);
					if (node) {
						node.position.x = this.dragNodeStartX + dx;
						node.position.y = this.dragNodeStartY + dy;
						this.updateNodePosition(node);
						this.updateEdgesDuringDrag(node.id);
					}
				});
			}
		};
		this._boundPointerUp = (e: PointerEvent) => {
			if (this.wiringActive && this.wiringDragging) {
				// Hit-test: did we release on a compatible input port?
				const el = this.shadow.elementFromPoint(e.clientX, e.clientY);
				const portGroup = el?.closest?.(".port-group") as SVGGElement | null;
				if (portGroup && portGroup.dataset.portDir === "in" && portGroup.dataset.nodeId !== this.wiringSourceNodeId) {
					this.completeWiring(portGroup.dataset.nodeId!);
				} else {
					// Fall back to click-to-wire mode (source still glowing)
					this.wiringDragging = false;
					const wireLayer = this.shadow.getElementById("wire-layer");
					if (wireLayer) wireLayer.innerHTML = "";
				}
				return;
			}
			// Edge drag end
			if (this.draggingEdgeKey) {
				this.draggingEdgeKey = null;
				this.edgeDragPointerId = null;
			}
			if (this.isPanning) {
				this.isPanning = false;
				svg.classList.remove("panning");
			}
			if (this.draggingNodeId) {
				const clickedNodeId = this.draggingNodeId;
				const wasDragged = nodeDragStarted;
				this.draggingNodeId = null;
				nodeDragStarted = false;
				svg.classList.remove("dragging");
				// Cancel any pending rAF
				if (this._dragRafId) { cancelAnimationFrame(this._dragRafId); this._dragRafId = null; }

				// Single click = select + open inline editor
				if (!wasDragged) {
					this.selectedNodeId = clickedNodeId;
					this.selectedEdgeKey = null;
					this.updateSelectionHighlight();
					this.enterInlineEdit(clickedNodeId);
				} else {
					// Full edge redraw for final accuracy
					this.redrawEdges();
					this.scheduleSave();
				}
			}
		};
		svg.addEventListener("pointermove", this._boundPointerMove);
		svg.addEventListener("pointerup", this._boundPointerUp);

		// Node interactions — delegate from node-layer
		const nodeLayer = this.shadow.getElementById("node-layer");
		if (nodeLayer) {
			nodeLayer.addEventListener("pointerdown", (e: PointerEvent) => {
				// Check port interaction FIRST
				const portGroup = (e.target as Element).closest(".port-group") as SVGGElement | null;
				if (portGroup) {
					e.stopPropagation();
					const portNodeId = portGroup.dataset.nodeId!;
					const portKind = portGroup.dataset.portKind as PortKind;
					const portDir = portGroup.dataset.portDir!;

					if (this.wiringActive) {
						// Click-to-wire: complete on compatible input port
						if (portDir === "in" && portNodeId !== this.wiringSourceNodeId) {
							this.completeWiring(portNodeId);
						} else {
							this.cancelWiring();
						}
						return;
					}

					// Start wiring from output port
					if (portDir === "out") {
						const portSide = portGroup.dataset.portSide as "left" | "right" | undefined;
						this.enterWiring(portNodeId, portKind, portSide);
						this.wiringDragging = true;
						this.wiringPointerX = e.clientX;
						this.wiringPointerY = e.clientY;
						svg.setPointerCapture(e.pointerId);
						return;
					}
					return;
				}

				const group = (e.target as Element).closest(".flow-node") as SVGGElement | null;
				if (!group) return;
				e.stopPropagation();
				const nodeId = group.dataset.nodeId;
				if (!nodeId) return;
				const node = this.nodes.find((n) => n.id === nodeId);
				if (!node) return;

				// If wiring is active and clicked on a node (not port), cancel
				if (this.wiringActive) {
					this.cancelWiring();
					return;
				}

				// Select
				this.selectedNodeId = nodeId;
				this.selectedEdgeKey = null;
				this.updateSelectionHighlight();

				// If click originated from HTML inside foreignObject, open inline edit but skip drag
				const target = e.target as Element;
				if (target instanceof HTMLElement && target.closest("foreignObject")) {
					this.enterInlineEdit(nodeId);
					return;
				}

				// Prepare drag (but don't start until threshold exceeded)
				nodeDragStarted = false;
				this.draggingNodeId = nodeId;
				this.dragStartX = e.clientX;
				this.dragStartY = e.clientY;
				this.dragNodeStartX = node.position.x;
				this.dragNodeStartY = node.position.y;
				svg.setPointerCapture(e.pointerId);
			});

			nodeLayer.addEventListener("dblclick", (e: MouseEvent) => {
				const group = (e.target as Element).closest(".flow-node") as SVGGElement | null;
				if (!group) return;
				const nodeId = group.dataset.nodeId;
				if (!nodeId) return;
				const node = this.nodes.find((n) => n.id === nodeId);
				if (!node) return;
				this.enterInlineEdit(nodeId);
			});

			// Hover: tooltip + edge highlighting
			let hoveredNodeId: string | null = null;
			nodeLayer.addEventListener("mouseover", (e: MouseEvent) => {
				const group = (e.target as Element).closest(".flow-node") as SVGGElement | null;
				if (!group) return;
				const nodeId = group.dataset.nodeId;
				if (nodeId && nodeId !== hoveredNodeId) {
					hoveredNodeId = nodeId;
					this.showNodeTooltip(nodeId, e);
					this.highlightNodeEdges(nodeId);
				}
			});
			nodeLayer.addEventListener("mouseout", (e: MouseEvent) => {
				const related = (e.relatedTarget as Element | null)?.closest?.(".flow-node");
				if (!related) {
					hoveredNodeId = null;
					this.hideNodeTooltip();
					this.unhighlightEdges();
				}
			});
		}

		// Toolbar buttons
		this.shadow.querySelectorAll("[data-canvas-action]").forEach((btn) => {
			btn.addEventListener("click", () => {
				const action = (btn as HTMLElement).dataset.canvasAction;
				if (action === "add-source") this.addNode("source");
				else if (action === "add-funnel") this.addNode("funnel");
				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 === "quick-fund") this.quickFund();
				else if (action === "share") this.shareState();
				else if (action === "tour") this.startTour();
				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(); }
				else if (action === "flow-picker") this.toggleFlowDropdown();
			});
		});

		// Flow dropdown items
		this.shadow.querySelectorAll("[data-flow-switch]").forEach((btn) => {
			btn.addEventListener("click", (e) => {
				e.stopPropagation();
				const flowId = (btn as HTMLElement).dataset.flowSwitch;
				if (flowId && flowId !== this.currentFlowId) this.switchToFlow(flowId);
				this.closeFlowDropdown();
			});
		});
		this.shadow.querySelectorAll("[data-flow-action]").forEach((btn) => {
			btn.addEventListener("click", (e) => {
				e.stopPropagation();
				const action = (btn as HTMLElement).dataset.flowAction;
				if (action === "new-flow") { this.closeFlowDropdown(); this.createNewFlow(); }
				else if (action === "manage-flows") { this.closeFlowDropdown(); this.openFlowManager(); }
			});
		});

		// Close dropdown on outside click
		this.shadow.addEventListener("click", (e) => {
			const dropdown = this.shadow.getElementById("flow-dropdown");
			if (dropdown && !dropdown.contains(e.target as Node)) this.closeFlowDropdown();
		});

		// Management modal listeners
		this.attachFlowManagerListeners();

		// Speed slider
		const speedSlider = this.shadow.getElementById("sim-speed-slider") as HTMLInputElement | null;
		if (speedSlider) {
			speedSlider.addEventListener("input", () => {
				this.simSpeedMs = parseInt(speedSlider.value, 10);
				const label = this.shadow.getElementById("sim-speed-label");
				if (label) label.textContent = `${this.simSpeedMs}ms`;
				if (this.isSimulating) this.startSimInterval();
			});
		}

		// Edge +/- buttons (delegated)
		const edgeLayer = this.shadow.getElementById("edge-layer");
		if (edgeLayer) {
			edgeLayer.addEventListener("click", (e: Event) => {
				const btn = (e.target as Element).closest("[data-edge-action]") as HTMLElement | null;
				if (!btn) return;
				e.stopPropagation();
				const action = btn.dataset.edgeAction; // "inc" or "dec"
				const fromId = btn.dataset.edgeFrom!;
				const toId = btn.dataset.edgeTo!;
				const allocType = btn.dataset.edgeType as "overflow" | "spending" | "source";
				this.handleAdjustAllocation(fromId, toId, allocType, action === "inc" ? 5 : -5);
			});

			// Edge selection — click on edge path (not buttons)
			edgeLayer.addEventListener("pointerdown", (e: PointerEvent) => {
				const target = e.target as Element;
				// Ignore clicks on +/- buttons or drag handle
				if (target.closest("[data-edge-action]")) return;
				if (target.closest(".edge-drag-handle")) return;

				const edgeGroup = target.closest(".edge-group") as SVGGElement | null;
				if (!edgeGroup) return;
				e.stopPropagation();

				const fromId = edgeGroup.dataset.from!;
				const toId = edgeGroup.dataset.to!;
				const edgeType = edgeGroup.dataset.edgeType || "source";
				const key = `${fromId}::${toId}::${edgeType}`;

				this.selectedEdgeKey = key;
				this.selectedNodeId = null;
				this.updateSelectionHighlight();
			});

			// Double-click edge → open source node editor
			edgeLayer.addEventListener("dblclick", (e: Event) => {
				const target = e.target as Element;
				if (target.closest("[data-edge-action]")) return;
				if (target.closest(".edge-drag-handle")) return;

				const edgeGroup = target.closest(".edge-group") as SVGGElement | null;
				if (!edgeGroup) return;
				e.stopPropagation();

				const fromId = edgeGroup.dataset.from!;
				this.openEditor(fromId);
			});

			// Edge drag handle — pointerdown to start dragging
			edgeLayer.addEventListener("pointerdown", (e: PointerEvent) => {
				const handle = (e.target as Element).closest(".edge-drag-handle") as SVGElement | null;
				if (!handle) return;
				e.stopPropagation();
				e.preventDefault();

				const edgeGroup = handle.closest(".edge-group") as SVGGElement | null;
				if (!edgeGroup) return;

				const fromId = edgeGroup.dataset.from!;
				const toId = edgeGroup.dataset.to!;
				const edgeType = edgeGroup.dataset.edgeType || "source";
				this.draggingEdgeKey = `${fromId}::${toId}::${edgeType}`;
				this.edgeDragPointerId = e.pointerId;
				(e.target as Element).setPointerCapture?.(e.pointerId);
			});

			// Double-click drag handle → remove waypoint
			edgeLayer.addEventListener("dblclick", (e: Event) => {
				const handle = (e.target as Element).closest(".edge-drag-handle") as SVGElement | null;
				if (!handle) return;
				e.stopPropagation();

				const edgeGroup = handle.closest(".edge-group") as SVGGElement | null;
				if (!edgeGroup) return;

				const fromId = edgeGroup.dataset.from!;
				const toId = edgeGroup.dataset.to!;
				const edgeType = edgeGroup.dataset.edgeType || "source";
				this.removeEdgeWaypoint(fromId, toId, edgeType);
			});
		}

		// Touch gesture handling for two-finger pan + pinch-to-zoom
		const getTouchCenter = (touches: TouchList) => ({
			x: (touches[0].clientX + touches[1].clientX) / 2,
			y: (touches[0].clientY + touches[1].clientY) / 2,
		});
		const getTouchDist = (touches: TouchList) => {
			const dx = touches[0].clientX - touches[1].clientX;
			const dy = touches[0].clientY - touches[1].clientY;
			return Math.hypot(dx, dy);
		};

		svg.addEventListener("touchstart", (e: TouchEvent) => {
			if (e.touches.length === 2) {
				e.preventDefault();
				this.isTouchPanning = true;
				// Cancel any pointer-based pan or node drag
				this.isPanning = false;
				if (this.draggingNodeId) {
					this.draggingNodeId = null;
					nodeDragStarted = false;
					svg.classList.remove("dragging");
				}
				if (this.wiringActive) this.cancelWiring();
				this.lastTouchCenter = getTouchCenter(e.touches);
				this.lastTouchDist = getTouchDist(e.touches);
			}
		}, { passive: false });

		svg.addEventListener("touchmove", (e: TouchEvent) => {
			if (e.touches.length === 2 && this.isTouchPanning) {
				e.preventDefault();
				const currentCenter = getTouchCenter(e.touches);
				const currentDist = getTouchDist(e.touches);

				if (this.lastTouchCenter) {
					// Two-finger pan
					this.canvasPanX += currentCenter.x - this.lastTouchCenter.x;
					this.canvasPanY += currentCenter.y - this.lastTouchCenter.y;
				}

				if (this.lastTouchDist && this.lastTouchDist > 0) {
					// Pinch-to-zoom around gesture center
					const zoomDelta = currentDist / this.lastTouchDist;
					const newZoom = Math.max(0.2, Math.min(5, this.canvasZoom * zoomDelta));
					const rect = svg.getBoundingClientRect();
					const cx = currentCenter.x - rect.left;
					const cy = currentCenter.y - rect.top;
					this.canvasPanX = cx - (cx - this.canvasPanX) * (newZoom / this.canvasZoom);
					this.canvasPanY = cy - (cy - this.canvasPanY) * (newZoom / this.canvasZoom);
					this.canvasZoom = newZoom;
				}

				this.lastTouchCenter = currentCenter;
				this.lastTouchDist = currentDist;
				this.updateCanvasTransform();
			}
		}, { passive: false });

		svg.addEventListener("touchend", (e: TouchEvent) => {
			if (e.touches.length < 2) {
				this.lastTouchCenter = null;
				this.lastTouchDist = null;
				this.isTouchPanning = false;
			}
		});

		// Keyboard
		this._boundKeyDown = (e: KeyboardEvent) => {
			// Use composedPath to pierce Shadow DOM retargeting (e.target is the host element, not the input)
			const el = (e.composedPath()[0] || e.target) as HTMLElement;
			const tag = el.tagName;
			if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable) return;
			// Also skip if a modal overlay is open or the editor panel is focused
			if (document.getElementById("onramp-modal") || el.closest?.("[style*='z-index:99999']")) return;
			if (this.editingNodeId || this.inlineEditNodeId) {
				if (e.key === "Delete" || e.key === "Backspace") return;
			}

			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();
			}
			else if (e.key === " ") { e.preventDefault(); this.toggleSimulation(); }
			else if (e.key === "Delete" || e.key === "Backspace") { if (this.selectedNodeId) this.deleteNode(this.selectedNodeId); }
			else if (e.key === "f" || e.key === "F") this.fitView();
			else if (e.key === "=" || e.key === "+") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); }
			else if (e.key === "-") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); }
		};
		document.addEventListener("keydown", this._boundKeyDown);
	}

	// ─── Inflow satisfaction computation ─────────────────

	private computeInflowSatisfaction(): Map<string, { actual: number; needed: number; ratio: number }> {
		const result = new Map<string, { actual: number; needed: number; ratio: number }>();

		for (const n of this.nodes) {
			if (n.type === "funnel") {
				const d = n.data as FunnelNodeData;
				const needed = d.inflowRate || 1;
				let actual = 0;
				// Sum source→funnel allocations
				for (const src of this.nodes) {
					if (src.type === "source") {
						const sd = src.data as SourceNodeData;
						for (const alloc of sd.targetAllocations) {
							if (alloc.targetId === n.id) actual += sd.flowRate * (alloc.percentage / 100);
						}
					}
					// Sum overflow from parent funnels
					if (src.type === "funnel" && src.id !== n.id) {
						const fd = src.data as FunnelNodeData;
						const excess = Math.max(0, fd.currentValue - fd.maxThreshold);
						for (const alloc of fd.overflowAllocations) {
							if (alloc.targetId === n.id) actual += excess * (alloc.percentage / 100);
						}
					}
					// Sum overflow from parent outcomes
					if (src.type === "outcome") {
						const od = src.data as OutcomeNodeData;
						const excess = Math.max(0, od.fundingReceived - od.fundingTarget);
						for (const alloc of (od.overflowAllocations || [])) {
							if (alloc.targetId === n.id) actual += excess * (alloc.percentage / 100);
						}
					}
				}
				result.set(n.id, { actual, needed, ratio: Math.min(actual / needed, 2) });
			}
			if (n.type === "outcome") {
				const d = n.data as OutcomeNodeData;
				const needed = Math.max(d.fundingTarget, 1);
				const actual = d.fundingReceived;
				result.set(n.id, { actual, needed, ratio: Math.min(actual / needed, 2) });
			}
		}
		return result;
	}

	// ─── Node SVG rendering ───────────────────────────────

	private renderAllNodes(): string {
		const satisfaction = this.computeInflowSatisfaction();
		return this.nodes.map((n) => this.renderNodeSvg(n, satisfaction)).join("");
	}

	private renderNodeSvg(n: FlowNode, satisfaction: Map<string, { actual: number; needed: number; ratio: number }>): string {
		const sel = this.selectedNodeId === n.id;
		if (n.type === "source") return this.renderSourceNodeSvg(n, sel);
		if (n.type === "funnel") return this.renderFunnelNodeSvg(n, sel, satisfaction.get(n.id));
		return this.renderOutcomeNodeSvg(n, sel, satisfaction.get(n.id));
	}

	private renderSourceNodeSvg(n: FlowNode, selected: boolean): string {
		const d = n.data as SourceNodeData;
		const s = this.getNodeSize(n);
		const x = n.position.x, y = n.position.y, w = s.w, h = s.h;

		// Valve color encodes sourceType
		const valveColors: Record<string, string> = { card: "#3b82f6", safe_wallet: "#10b981", ridentity: "#7c3aed", metamask: "#f6851b", unconfigured: "#64748b" };
		const valveColor = valveColors[d.sourceType] || "#64748b";
		const isConfigured = d.sourceType !== "unconfigured";

		// Pipe header dimensions
		const pipeH = 22;
		const pipeY = 0;
		const pipeRx = 6;

		// Valve body
		const valveR = 28;
		const valveCx = w / 2;
		const valveCy = pipeY + pipeH + valveR + 4;

		// Valve handle rotation: 45° configured, 90° unconfigured
		const handleAngle = isConfigured ? 45 : 90;

		// Spigot: trapezoid below valve
		const spigotTop = valveCy + valveR + 2;
		const spigotTopW = 24;
		const spigotBotW = 14;
		const spigotH = 20;
		const spigotPath = `M ${valveCx - spigotTopW / 2},${spigotTop} L ${valveCx + spigotTopW / 2},${spigotTop} L ${valveCx + spigotBotW / 2},${spigotTop + spigotH} L ${valveCx - spigotBotW / 2},${spigotTop + spigotH} Z`;

		// Flow stream at bottom — width proportional to flowRate
		const streamMaxW = w - 40;
		const streamW = Math.round(8 + Math.min(streamMaxW - 8, Math.sqrt(d.flowRate / 100) * (streamMaxW / 6)));
		const streamY = spigotTop + spigotH;
		const streamH = h - streamY;

		// Amount text
		const amountY = valveCy + valveR + spigotH + 8;

		// Allocation bar as SVG rects
		let allocBar = "";
		if (d.targetAllocations && d.targetAllocations.length > 0) {
			const barY = h - 10;
			const barW = w - 40;
			const barX = 20;
			let cx = barX;
			allocBar = d.targetAllocations.map(a => {
				const segW = (a.percentage / 100) * barW;
				const rect = `<rect x="${cx}" y="${barY}" width="${segW}" height="3" rx="1" fill="${a.color}" opacity="0.85"/>`;
				cx += segW + 1;
				return rect;
			}).join("");
		}

		return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
			<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="8" fill="transparent" stroke="none"/>
			<rect class="faucet-pipe" x="0" y="${pipeY}" width="${w}" height="${pipeH}" rx="${pipeRx}" fill="url(#faucet-pipe-grad)" stroke="${selected ? "var(--rflows-selected)" : "var(--rs-text-secondary)"}" stroke-width="${selected ? 2 : 1}"/>
			<text x="${w / 2}" y="${pipeY + pipeH / 2 + 1}" text-anchor="middle" dominant-baseline="central" fill="white" font-size="11" font-weight="600" pointer-events="none">${this.esc(d.label)}</text>
			<circle class="faucet-valve" cx="${valveCx}" cy="${valveCy}" r="${valveR}" fill="${valveColor}" stroke="${selected ? "var(--rflows-selected)" : "var(--rs-bg-surface)"}" stroke-width="${selected ? 2.5 : 1.5}" style="cursor:pointer"/>
			<g transform="rotate(${handleAngle},${valveCx},${valveCy})">
				<rect class="faucet-handle" x="${valveCx - 3}" y="${valveCy - valveR - 6}" width="6" height="${valveR * 2 + 12}" rx="3" fill="var(--rs-bg-surface)" opacity="0.7"/>
			</g>
			<path class="faucet-spigot" d="${spigotPath}" fill="url(#faucet-pipe-grad)" stroke="var(--rs-text-secondary)" stroke-width="1"/>
			<rect class="faucet-stream" x="${valveCx - streamW / 2}" y="${streamY}" width="${streamW}" height="${Math.max(streamH, 4)}" rx="3" fill="#10b981" opacity="${isConfigured ? 0.45 : 0.15}"/>
			<text x="${valveCx}" y="${amountY}" text-anchor="middle" fill="var(--rs-text-primary)" font-size="13" font-weight="700" font-family="ui-monospace,monospace" pointer-events="none">$${d.flowRate.toLocaleString()}/mo</text>
			${allocBar}
			${this.renderPortsSvg(n)}
		</g>`;
	}

	private renderFunnelNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string {
		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 fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1));

		const isOverflow = d.currentValue > d.maxThreshold;
		const isCritical = d.currentValue < d.minThreshold;
		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" : "Sufficient";

		// Tank shape parameters
		const r = 12;
		const pipeW = 30;                   // overflow pipe extension from wall
		const basePipeH = 24;               // base pipe height
		const taperStart = 0.80;            // body tapers at 80% down
		// Drain width proportional to outflow: wider drain = more outflow
		const outflow = d.desiredOutflow || 0;
		const outflowRatio = Math.min(1, outflow / 10000);
		const taperInset = 0.30 - outflowRatio * 0.18; // 0.30 (narrow/$0) → 0.12 (wide/$3000)
		const insetPx = Math.round(w * taperInset);
		const taperY = Math.round(h * taperStart);
		const clipId = `funnel-clip-${n.id}`;

		// Interior zone boundaries
		const zoneTop = 36;
		const zoneBot = h - 6;
		const zoneH = zoneBot - zoneTop;

		// Zone fractions for 3 zones: Critical (below min), Sufficient (min-max), Overflow (above max)
		const minFrac = d.minThreshold / (d.maxCapacity || 1);
		const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
		const criticalPct = minFrac;
		const sufficientPct = maxFrac - minFrac;
		const overflowPct = Math.max(0, 1 - maxFrac);
		const criticalH = zoneH * criticalPct;
		const sufficientH = zoneH * sufficientPct;
		const overflowH = zoneH * overflowPct;

		// Pipe position at max threshold line
		const maxLineY = zoneTop + zoneH * (1 - maxFrac);
		let pipeH = basePipeH;
		let pipeY = Math.round(maxLineY - 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 + Math.round(excessRatio * 20);
			pipeY = Math.round(maxLineY - pipeH / 2);
		}

		// 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(" ");

		// Fill level
		const totalFillH = zoneH * fillPct;
		const fillY = zoneTop + zoneH - totalFillH;

		// Threshold lines: only min and max (2 lines, 3 zones)
		const minLineY = zoneTop + zoneH * (1 - minFrac);

		const thresholdLines = `
			<line class="threshold-line" x1="6" x2="${w - 6}" y1="${minLineY}" y2="${minLineY}" stroke="var(--rflows-status-critical)" stroke-width="2" stroke-dasharray="6 4" opacity="0.8"/>
			<text x="10" y="${minLineY - 5}" style="fill:var(--rflows-status-critical)" font-size="10" font-weight="500" opacity="0.9">Min</text>
			<line class="threshold-line" x1="6" x2="${w - 6}" y1="${maxLineY}" y2="${maxLineY}" stroke="var(--rflows-status-overflow)" stroke-width="2" stroke-dasharray="6 4" opacity="0.8"/>
			<text x="10" y="${maxLineY - 5}" style="fill:var(--rflows-status-overflow)" font-size="10" font-weight="500" opacity="0.9">Max</text>`;

		// Inflow satisfaction bar
		const satBarY = 50;
		const satBarW = w - 48;
		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="var(--rflows-sat-border)" stroke-width="1"` : "";

		const glowStyle = isOverflow ? "filter: drop-shadow(0 0 8px rgba(16,185,129,0.5))"
			: !isCritical ? "filter: drop-shadow(0 0 8px rgba(245,158,11,0.4))" : "";

		// Rate labels
		const inflowLabel = `${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 = baseRate * rateMultiplier;
		const excess = Math.max(0, d.currentValue - d.maxThreshold);
		const overflowLabel = isOverflow ? this.formatDollar(excess) : "";

		// Status badge colors for HTML
		const statusBadgeBg = isCritical ? "rgba(239,68,68,0.15)" : isOverflow ? "rgba(16,185,129,0.15)" : "rgba(245,158,11,0.15)";
		const statusBadgeColor = isCritical ? "#ef4444" : isOverflow ? "#10b981" : "#f59e0b";

		return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})" style="${glowStyle}">
			<defs>
				<clipPath id="${clipId}"><path d="${tankPath}"/></clipPath>
			</defs>
			${isOverflow ? `<path d="${tankPath}" fill="none" stroke="var(--rflows-status-overflow)" stroke-width="2.5" opacity="0.4" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""}
			<path class="node-bg" d="${tankPath}" style="fill:var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : borderColorVar}" stroke-width="${selected ? 3.5 : 2.5}"/>
			<g clip-path="url(#${clipId})">
				<rect x="${-pipeW}" y="${zoneTop + overflowH + sufficientH}" width="${w + pipeW * 2}" height="${criticalH}" style="fill:var(--rflows-zone-drain);opacity:var(--rflows-zone-drain-opacity)"/>
				<rect x="${-pipeW}" y="${zoneTop + overflowH}" width="${w + pipeW * 2}" height="${sufficientH}" style="fill:var(--rflows-zone-healthy);opacity:var(--rflows-zone-healthy-opacity)"/>
				<rect x="${-pipeW}" y="${zoneTop}" width="${w + pipeW * 2}" height="${overflowH}" style="fill:var(--rflows-zone-overflow);opacity:var(--rflows-zone-overflow-opacity)"/>
				<rect class="funnel-fill-rect" data-node-id="${n.id}" x="${-pipeW}" y="${fillY}" width="${w + pipeW * 2}" height="${totalFillH}" style="fill:${fillColor};opacity:var(--rflows-fill-opacity)"/>
				${thresholdLines}
			</g>
			<rect class="funnel-pipe ${isOverflow ? "funnel-pipe--active" : ""}" data-pipe="left" data-node-id="${n.id}" x="${-pipeW}" y="${pipeY}" width="${pipeW}" height="${pipeH}" rx="4" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.85 : 0.3}"/>
			<rect class="funnel-pipe ${isOverflow ? "funnel-pipe--active" : ""}" data-pipe="right" data-node-id="${n.id}" x="${w}" y="${pipeY}" width="${pipeW}" height="${pipeH}" rx="4" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.85 : 0.3}"/>
			<rect x="24" y="${satBarY}" width="${satBarW}" height="8" rx="4" style="fill:var(--rs-bg-surface-raised)" opacity="0.3" class="satisfaction-bar-bg"/>
			<rect x="24" y="${satBarY}" width="${satFillW}" height="8" rx="4" style="fill:var(--rflows-sat-bar)" class="satisfaction-bar-fill" ${satBarBorder}/>
			<rect class="funnel-valve-bar" x="${insetPx + 2}" y="${h - 10}" width="${w - insetPx * 2 - 4}" height="8" rx="3" style="fill:var(--rflows-label-spending);opacity:0.6;cursor:ew-resize"/>
			<g class="funnel-valve-handle" data-handle="valve" data-node-id="${n.id}">
				<rect x="${insetPx - 8}" y="${h - 16}" width="${w - insetPx * 2 + 16}" height="18" rx="5"
					style="fill:var(--rflows-label-spending);cursor:ew-resize;stroke:white;stroke-width:1.5"/>
				<text x="${w / 2}" y="${h - 4}" text-anchor="middle" fill="white" font-size="11" font-weight="600" pointer-events="none">
					◁ ${this.formatDollar(outflow)}/mo ▷
				</text>
			</g>
			<g class="funnel-height-handle" data-handle="height" data-node-id="${n.id}">
				<rect x="${w / 2 - 28}" y="${h + 4}" width="56" height="12" rx="5"
					style="fill:var(--rs-border-strong);cursor:ns-resize;stroke:var(--rs-text-muted);stroke-width:1"/>
				<text x="${w / 2}" y="${h + 13}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9" font-weight="500" pointer-events="none">⇕ capacity</text>
			</g>
			<foreignObject x="${-pipeW - 10}" y="-28" width="${w + pipeW * 2 + 20}" height="${h + 56}" class="funnel-overlay">
				<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;position:relative;font-family:system-ui,-apple-system,sans-serif;pointer-events:none">
					<div style="position:absolute;top:0;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:12px;font-weight:500;color:#10b981;opacity:0.9">\u2193 ${inflowLabel}</div>
					<div style="position:absolute;top:38px;left:${pipeW + 10}px;right:${pipeW + 10}px;display:flex;align-items:center;justify-content:space-between">
						<span style="font-size:14px;font-weight:600;color:var(--rs-text-primary)">${this.esc(d.label)}</span>
						<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:9999px;background:${statusBadgeBg};color:${statusBadgeColor}">${statusLabel}</span>
					</div>
					<div style="position:absolute;top:${satBarY + 18 + 28}px;left:${pipeW + 10}px;right:${pipeW + 10}px;text-align:center;font-size:10px;color:var(--rs-text-secondary)">${satLabel}</div>
					${criticalH > 20 ? `<div style="position:absolute;top:${zoneTop + overflowH + sufficientH + criticalH / 2 + 28 - 6}px;width:100%;text-align:center;font-size:10px;font-weight:600;color:#ef4444;opacity:0.5">CRITICAL</div>` : ""}
					${sufficientH > 20 ? `<div style="position:absolute;top:${zoneTop + overflowH + sufficientH / 2 + 28 - 6}px;width:100%;text-align:center;font-size:10px;font-weight:600;color:#f59e0b;opacity:0.5">SUFFICIENT</div>` : ""}
					${overflowH > 20 ? `<div style="position:absolute;top:${zoneTop + overflowH / 2 + 28 - 6}px;width:100%;text-align:center;font-size:10px;font-weight:600;color:#f59e0b;opacity:0.5">OVERFLOW</div>` : ""}
					<div class="funnel-value-text" data-node-id="${n.id}" style="position:absolute;bottom:${insetPx + 56}px;width:100%;text-align:center;font-size:13px;font-weight:500;color:var(--rs-text-muted)">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()}</div>
					<div style="position:absolute;bottom:10px;width:100%;text-align:center;font-size:12px;font-weight:600;color:#34d399">${this.formatDollar(outflow)}/mo \u25BE</div>
					${isOverflow ? `<div style="position:absolute;top:${pipeY + pipeH / 2 + 28 - 6}px;left:0;font-size:11px;font-weight:500;color:#6ee7b7;opacity:0.8">${overflowLabel}</div>
					<div style="position:absolute;top:${pipeY + pipeH / 2 + 28 - 6}px;right:0;font-size:11px;font-weight:500;color:#6ee7b7;opacity:0.8">${overflowLabel}</div>` : ""}
				</div>
			</foreignObject>
			${this.renderPortsSvg(n)}
		</g>`;
	}

	private renderOutcomeNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string {
		const d = n.data as OutcomeNodeData;
		const s = this.getNodeSize(n);
		const x = n.position.x, y = n.position.y, w = s.w, h = s.h;
		const fillPct = d.fundingTarget > 0 ? Math.min(1, d.fundingReceived / d.fundingTarget) : 0;
		const statusColors: Record<string, string> = { completed: "#10b981", blocked: "#ef4444", "in-progress": "#3b82f6", "not-started": "#64748b" };
		const statusColor = statusColors[d.status] || "#64748b";
		const statusLabel = d.status.replace("-", " ").replace(/\b\w/g, c => c.toUpperCase());

		// Phase indicators
		let phaseHtml = "";
		if (d.phases && d.phases.length > 0) {
			const unlockedCount = d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length;
			const phaseSegs = d.phases.map((p, i) => {
				const unlocked = d.fundingReceived >= p.fundingThreshold;
				return `<div style="flex:1;height:4px;border-radius:2px;background:${unlocked ? "#10b981" : "var(--rs-border)"}"></div>`;
			}).join("");
			phaseHtml = `<div style="display:flex;gap:2px;margin:6px 0">${phaseSegs}</div>
				<div style="font-size:10px;color:var(--rs-text-secondary);text-align:center">${unlockedCount}/${d.phases.length} phases unlocked</div>`;
		}

		const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`;

		return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
			<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="12" fill="var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : statusColor}" stroke-width="${selected ? 3 : 2}"/>
			<foreignObject x="0" y="0" width="${w}" height="${h}">
				<div xmlns="http://www.w3.org/1999/xhtml" class="node-card outcome-card ${selected ? "selected" : ""}">
					<div class="card-header" style="background:var(--rs-bg-surface-raised);padding:8px 12px;border-bottom:1px solid var(--rs-border)">
						<div style="display:flex;align-items:center;justify-content:space-between">
							<span style="font-size:13px;font-weight:600;color:var(--rs-text-primary)">${this.esc(d.label)}</span>
							<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:9999px;background:${statusColor}20;color:${statusColor}">${statusLabel}</span>
						</div>
					</div>
					<div style="padding:8px 12px">
						<div style="font-size:11px;color:var(--rs-text-muted);margin-bottom:4px">${Math.round(fillPct * 100)}% funded — ${dollarLabel}</div>
						<div style="height:6px;background:var(--rs-border);border-radius:3px;overflow:hidden">
							<div style="width:${fillPct * 100}%;height:100%;background:${statusColor};border-radius:3px;transition:width 0.3s"></div>
						</div>
						${phaseHtml}
					</div>
				</div>
			</foreignObject>
			${this.renderPortsSvg(n)}
		</g>`;
	}

	private renderAllocBar(allocs: { percentage: number; color: string }[], parentW: number, y: number): string {
		if (!allocs || allocs.length === 0) return "";
		let bar = "";
		let cx = 10;
		const barW = parentW - 20;
		for (const a of allocs) {
			const segW = barW * (a.percentage / 100);
			bar += `<rect x="${cx}" y="${y}" width="${segW}" height="3" rx="1" fill="${a.color}" opacity="0.7"/>`;
			cx += segW;
		}
		return bar;
	}

	// ─── Edge rendering ───────────────────────────────────

	private formatDollar(amount: number): string {
		if (amount >= 1_000_000) return `$${(amount / 1_000_000).toFixed(1)}M`;
		if (amount >= 1_000) return `$${(amount / 1_000).toFixed(1)}k`;
		return `$${Math.round(amount)}`;
	}

	private renderAllEdges(): string {
		// First pass: compute actual dollar flow per edge
		interface EdgeInfo {
			fromNode: FlowNode;
			toNode: FlowNode;
			fromPort: PortKind;
			fromSide?: "left" | "right";
			color: string;
			flowAmount: number;
			pct: number;
			dashed: boolean;
			fromId: string;
			toId: string;
			edgeType: string;
			waypoint?: { x: number; y: number };
		}
		const edges: EdgeInfo[] = [];

		for (const n of this.nodes) {
			if (n.type === "source") {
				const d = n.data as SourceNodeData;
				for (const alloc of d.targetAllocations) {
					const target = this.nodes.find((t) => t.id === alloc.targetId);
					if (!target) continue;
					const flowAmount = d.flowRate * (alloc.percentage / 100);
					edges.push({
						fromNode: n, toNode: target, fromPort: "outflow",
						color: "var(--rflows-edge-inflow)", flowAmount,
						pct: alloc.percentage, dashed: false,
						fromId: n.id, toId: alloc.targetId, edgeType: "source",
						waypoint: alloc.waypoint,
					});
				}
			}
			if (n.type === "funnel") {
				const d = n.data as FunnelNodeData;
				// Overflow edges — actual excess flow (routed through side ports)
				for (const alloc of d.overflowAllocations) {
					const target = this.nodes.find((t) => t.id === alloc.targetId);
					if (!target) continue;
					const excess = Math.max(0, d.currentValue - d.maxThreshold);
					const flowAmount = excess * (alloc.percentage / 100);
					const side = this.getOverflowSideForTarget(n, target);
					edges.push({
						fromNode: n, toNode: target, fromPort: "overflow",
						fromSide: side,
						color: "var(--rflows-edge-overflow)", flowAmount,
						pct: alloc.percentage, dashed: true,
						fromId: n.id, toId: alloc.targetId, edgeType: "overflow",
						waypoint: alloc.waypoint,
					});
				}
				// Spending edges — rate-based drain
				for (const alloc of d.spendingAllocations) {
					const target = this.nodes.find((t) => t.id === alloc.targetId);
					if (!target) continue;
					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 drain = d.inflowRate * rateMultiplier;
					const flowAmount = drain * (alloc.percentage / 100);
					edges.push({
						fromNode: n, toNode: target, fromPort: "spending",
						color: "var(--rflows-edge-spending)", flowAmount,
						pct: alloc.percentage, dashed: false,
						fromId: n.id, toId: alloc.targetId, edgeType: "spending",
						waypoint: alloc.waypoint,
					});
				}
			}
			// Outcome overflow edges
			if (n.type === "outcome") {
				const d = n.data as OutcomeNodeData;
				const allocs = d.overflowAllocations || [];
				for (const alloc of allocs) {
					const target = this.nodes.find((t) => t.id === alloc.targetId);
					if (!target) continue;
					const excess = Math.max(0, d.fundingReceived - d.fundingTarget);
					const flowAmount = excess * (alloc.percentage / 100);
					edges.push({
						fromNode: n, toNode: target, fromPort: "overflow",
						color: "var(--rflows-edge-overflow)", flowAmount,
						pct: alloc.percentage, dashed: true,
						fromId: n.id, toId: alloc.targetId, edgeType: "overflow",
						waypoint: alloc.waypoint,
					});
				}
			}
		}

		// Second pass: render edges with flow-value-relative widths (Sankey-style)
		const MAX_EDGE_W = 28;
		const MIN_EDGE_W = 3;
		const maxFlow = Math.max(...edges.map(e => e.flowAmount), 1);
		let html = "";
		for (const e of edges) {
			const from = this.getPortPosition(e.fromNode, e.fromPort, e.fromSide);
			const to = this.getPortPosition(e.toNode, "inflow");
			const isGhost = e.flowAmount === 0;
			const strokeW = isGhost ? 1 : MIN_EDGE_W + (e.flowAmount / maxFlow) * (MAX_EDGE_W - MIN_EDGE_W);
			const label = isGhost ? `${e.pct}%` : `${this.formatDollar(e.flowAmount)} (${e.pct}%)`;
			html += this.renderEdgePath(
				from.x, from.y, to.x, to.y,
				e.color, strokeW, e.dashed, isGhost,
				label, e.fromId, e.toId, e.edgeType,
				e.fromSide, e.waypoint,
			);
		}
		return html;
	}

	private renderEdgePath(
		x1: number, y1: number, x2: number, y2: number,
		color: string, strokeW: number, dashed: boolean, ghost: boolean,
		label: string, fromId: string, toId: string, edgeType: string,
		fromSide?: "left" | "right",
		waypoint?: { x: number; y: number },
	): string {
		let d: string;
		let midX: number;
		let midY: number;

		if (waypoint) {
			// Cubic Bezier that passes through waypoint at t=0.5:
			// P(0.5) = 0.125*P0 + 0.375*C1 + 0.375*C2 + 0.125*P3
			// To pass through waypoint W: C1 = (4W - P0 - P3) / 3 blended toward start,
			// C2 = (4W - P0 - P3) / 3 blended toward end
			const cx1 = (4 * waypoint.x - x1 - x2) / 3;
			const cy1 = (4 * waypoint.y - y1 - y2) / 3;
			const cx2 = cx1;
			const cy2 = cy1;
			// Blend control points to retain start/end tangent direction
			const c1x = x1 + (cx1 - x1) * 0.8;
			const c1y = y1 + (cy1 - y1) * 0.8;
			const c2x = x2 + (cx2 - x2) * 0.8;
			const c2y = y2 + (cy2 - y2) * 0.8;
			d = `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`;
			midX = waypoint.x;
			midY = waypoint.y;
		} else if (fromSide) {
			// Side port: curve outward horizontally first, then turn toward target
			const burst = Math.max(100, strokeW * 8);
			const outwardX = fromSide === "left" ? x1 - burst : x1 + burst;
			d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`;
			midX = (x1 + outwardX + x2) / 3;
			midY = (y1 + y2) / 2;
		} else {
			const cy1 = y1 + (y2 - y1) * 0.4;
			const cy2 = y1 + (y2 - y1) * 0.6;
			d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`;
			midX = (x1 + x2) / 2;
			midY = (y1 + y2) / 2;
		}

		// Invisible wide hit area for click/selection
		const hitPath = `<path d="${d}" fill="none" stroke="transparent" stroke-width="${Math.max(12, strokeW * 3)}" class="edge-hit-area" style="cursor:pointer"/>`;

		if (ghost) {
			return `<g class="edge-group" data-from="${fromId}" data-to="${toId}" data-edge-type="${edgeType}">
				${hitPath}
				<path d="${d}" fill="none" style="stroke:${color}" stroke-width="1" stroke-opacity="0.2" stroke-dasharray="4 6" class="edge-ghost"/>
				<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
					<rect x="-34" y="-12" width="68" height="24" rx="6" style="fill:var(--rs-bg-surface);stroke:var(--rs-bg-surface-raised)" stroke-width="1" opacity="0.5"/>
					<text x="-14" y="5" style="fill:${color}" font-size="11" font-weight="600" text-anchor="middle" opacity="0.5">${label}</text>
					<g data-edge-action="dec" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
						<rect x="-32" y="-9" width="16" height="18" rx="3" style="fill:var(--rs-bg-surface-raised)"/>
						<text x="-24" y="5" style="fill:var(--rs-text-primary)" font-size="13" text-anchor="middle">&minus;</text>
					</g>
					<g data-edge-action="inc" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
						<rect x="16" y="-9" width="16" height="18" rx="3" style="fill:var(--rs-bg-surface-raised)"/>
						<text x="24" y="5" style="fill:var(--rs-text-primary)" font-size="13" text-anchor="middle">+</text>
					</g>
				</g>
			</g>`;
		}

		const overflowMul = dashed ? 1.3 : 1;
		const finalStrokeW = strokeW * overflowMul;
		const animClass = dashed ? "edge-path-overflow" : "edge-path-animated";
		// Wider label box to fit dollar amounts
		const labelW = Math.max(68, label.length * 7 + 36);
		const halfW = labelW / 2;
		// Drag handle at midpoint
		const dragHandle = `<circle cx="${midX}" cy="${midY - 18}" r="5" class="edge-drag-handle"/>`;
		// Arrow marker
		const markerId = edgeType === "overflow" ? "arrowhead-overflow" : edgeType === "spending" ? "arrowhead-spending" : "arrowhead-inflow";
		return `<g class="edge-group" data-from="${fromId}" data-to="${toId}" data-edge-type="${edgeType}">
			${hitPath}
			<path d="${d}" fill="none" style="stroke:${color}" stroke-width="${finalStrokeW * 2.5}" stroke-opacity="0.12" class="edge-glow"/>
			<path d="${d}" fill="none" style="stroke:${color}" stroke-width="${finalStrokeW}" stroke-opacity="0.8" stroke-linecap="round" class="${animClass}" marker-end="url(#${markerId})"/>
			${dashed ? `<circle cx="${x1}" cy="${y1}" r="${Math.max(4, finalStrokeW * 0.6)}" style="fill:${color}" opacity="0.5" class="edge-splash"><animate attributeName="r" values="${Math.max(4, finalStrokeW * 0.6)};${Math.max(8, finalStrokeW)};${Math.max(4, finalStrokeW * 0.6)}" dur="1.2s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.5;0.2;0.5" dur="1.2s" repeatCount="indefinite"/></circle>` : ""}
			${dragHandle}
			<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
				<rect x="${-halfW}" y="-12" width="${labelW}" height="24" rx="6" style="fill:var(--rs-bg-surface);stroke:var(--rs-bg-surface-raised)" stroke-width="1" opacity="0.9"/>
				<text x="0" y="5" style="fill:${color}" font-size="10" font-weight="600" text-anchor="middle">${label}</text>
				<g data-edge-action="dec" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
					<rect x="${-halfW + 2}" y="-9" width="16" height="18" rx="3" style="fill:var(--rs-bg-surface-raised)"/>
					<text x="${-halfW + 10}" y="5" style="fill:var(--rs-text-primary)" font-size="13" text-anchor="middle">&minus;</text>
				</g>
				<g data-edge-action="inc" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
					<rect x="${halfW - 18}" y="-9" width="16" height="18" rx="3" style="fill:var(--rs-bg-surface-raised)"/>
					<text x="${halfW - 10}" y="5" style="fill:var(--rs-text-primary)" font-size="13" text-anchor="middle">+</text>
				</g>
			</g>
		</g>`;
	}

	private redrawEdges() {
		const edgeLayer = this.shadow.getElementById("edge-layer");
		if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges();
	}

	/** Pure path computation — returns { d, midX, midY } */
	private computeEdgePath(
		x1: number, y1: number, x2: number, y2: number,
		strokeW: number, fromSide?: "left" | "right",
		waypoint?: { x: number; y: number },
	): { d: string; midX: number; midY: number } {
		let d: string, midX: number, midY: number;
		if (waypoint) {
			const cx1 = (4 * waypoint.x - x1 - x2) / 3;
			const cy1 = (4 * waypoint.y - y1 - y2) / 3;
			const c1x = x1 + (cx1 - x1) * 0.8;
			const c1y = y1 + (cy1 - y1) * 0.8;
			const c2x = x2 + (cx1 - x2) * 0.8;
			const c2y = y2 + (cy1 - y2) * 0.8;
			d = `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`;
			midX = waypoint.x;
			midY = waypoint.y;
		} else if (fromSide) {
			const burst = Math.max(100, strokeW * 8);
			const outwardX = fromSide === "left" ? x1 - burst : x1 + burst;
			d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`;
			midX = (x1 + outwardX + x2) / 3;
			midY = (y1 + y2) / 2;
		} else {
			const cy1 = y1 + (y2 - y1) * 0.4;
			const cy2 = y1 + (y2 - y1) * 0.6;
			d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`;
			midX = (x1 + x2) / 2;
			midY = (y1 + y2) / 2;
		}
		return { d, midX, midY };
	}

	/** Lightweight edge update during drag — only patches path `d` attrs and label positions for edges connected to dragged node */
	private updateEdgesDuringDrag(nodeId: string) {
		const edgeLayer = this.shadow.getElementById("edge-layer");
		if (!edgeLayer) return;
		const groups = edgeLayer.querySelectorAll(`.edge-group[data-from="${nodeId}"], .edge-group[data-to="${nodeId}"]`);
		for (const g of groups) {
			const el = g as SVGGElement;
			const fromId = el.dataset.from!;
			const toId = el.dataset.to!;
			const edgeType = el.dataset.edgeType || "source";
			const fromNode = this.nodes.find(n => n.id === fromId);
			const toNode = this.nodes.find(n => n.id === toId);
			if (!fromNode || !toNode) continue;

			// Determine port kinds and side
			let fromPort: PortKind = "outflow";
			let fromSide: "left" | "right" | undefined;
			if (edgeType === "overflow") {
				fromPort = "overflow";
				fromSide = this.getOverflowSideForTarget(fromNode, toNode);
			} else if (edgeType === "spending") {
				fromPort = "spending";
			}

			const from = this.getPortPosition(fromNode, fromPort, fromSide);
			const to = this.getPortPosition(toNode, "inflow");

			// Get waypoint from allocation
			const alloc = this.findEdgeAllocation(fromId, toId, edgeType);
			const waypoint = alloc?.waypoint;

			// Compute stroke width (approximate — use existing path width)
			const mainPath = el.querySelector<SVGPathElement>(".edge-path-animated, .edge-path-overflow, .edge-ghost");
			const existingStrokeW = mainPath ? parseFloat(mainPath.getAttribute("stroke-width") || "4") : 4;

			const { d, midX, midY } = this.computeEdgePath(from.x, from.y, to.x, to.y, existingStrokeW, fromSide, waypoint);

			// Update all path elements in this group
			el.querySelectorAll("path").forEach(path => {
				path.setAttribute("d", d);
			});

			// Update label/control group position
			const ctrlGroup = el.querySelector(".edge-ctrl-group") as SVGGElement | null;
			if (ctrlGroup) ctrlGroup.setAttribute("transform", `translate(${midX},${midY})`);

			// Update drag handle
			const dragHandle = el.querySelector(".edge-drag-handle") as SVGCircleElement | null;
			if (dragHandle) {
				dragHandle.setAttribute("cx", String(midX));
				dragHandle.setAttribute("cy", String(midY - 18));
			}

			// Update splash circle for overflow edges
			const splash = el.querySelector(".edge-splash") as SVGCircleElement | null;
			if (splash) {
				splash.setAttribute("cx", String(from.x));
				splash.setAttribute("cy", String(from.y));
			}
		}
	}

	// ─── Edge waypoint helpers ──────────────────────────────

	private findEdgeAllocation(fromId: string, toId: string, edgeType: string): (OverflowAllocation | SpendingAllocation | SourceAllocation) | null {
		const node = this.nodes.find((n) => n.id === fromId);
		if (!node) return null;
		if (edgeType === "source" && node.type === "source") {
			return (node.data as SourceNodeData).targetAllocations.find((a) => a.targetId === toId) || null;
		}
		if (edgeType === "overflow") {
			if (node.type === "funnel") return (node.data as FunnelNodeData).overflowAllocations.find((a) => a.targetId === toId) || null;
			if (node.type === "outcome") return ((node.data as OutcomeNodeData).overflowAllocations || []).find((a) => a.targetId === toId) || null;
		}
		if (edgeType === "spending" && node.type === "funnel") {
			return (node.data as FunnelNodeData).spendingAllocations.find((a) => a.targetId === toId) || null;
		}
		return null;
	}

	private setEdgeWaypoint(edgeKey: string, x: number, y: number) {
		const [fromId, toId, edgeType] = edgeKey.split("::");
		const alloc = this.findEdgeAllocation(fromId, toId, edgeType);
		if (alloc) alloc.waypoint = { x, y };
	}

	private removeEdgeWaypoint(fromId: string, toId: string, edgeType: string) {
		const alloc = this.findEdgeAllocation(fromId, toId, edgeType);
		if (alloc) {
			delete alloc.waypoint;
			this.redrawEdges();
		}
	}

	// ─── Selection highlight ──────────────────────────────

	private updateSelectionHighlight() {
		const nodeLayer = this.shadow.getElementById("node-layer");
		if (!nodeLayer) return;
		nodeLayer.querySelectorAll(".flow-node").forEach((g) => {
			const el = g as SVGGElement;
			const isSelected = el.dataset.nodeId === this.selectedNodeId;
			el.classList.toggle("selected", isSelected);
			// Update SVG rect stroke
			const bg = el.querySelector(".node-bg") as SVGElement | null;
			if (bg) {
				if (isSelected) {
					bg.setAttribute("stroke", "var(--rflows-selected)");
					bg.setAttribute("stroke-width", "3");
				} else {
					const node = this.nodes.find((n) => n.id === el.dataset.nodeId);
					if (node) {
						const origColor = this.getNodeBorderColor(node);
						bg.setAttribute("stroke", origColor);
						bg.setAttribute("stroke-width", node.type === "outcome" ? "2" : "2");
					}
				}
			}
			// Update HTML card selected class
			const card = el.querySelector(".node-card") as HTMLElement | null;
			if (card) card.classList.toggle("selected", isSelected);
		});

		// Edge selection highlight
		const edgeLayer = this.shadow.getElementById("edge-layer");
		if (!edgeLayer) return;
		edgeLayer.querySelectorAll(".edge-group").forEach((g) => {
			const el = g as SVGGElement;
			const fromId = el.dataset.from;
			const toId = el.dataset.to;
			const edgeType = el.dataset.edgeType || "source";
			const key = `${fromId}::${toId}::${edgeType}`;
			el.classList.toggle("edge-group--selected", key === this.selectedEdgeKey);
		});
	}

	private getNodeBorderColor(n: FlowNode): string {
		if (n.type === "source") return "var(--rflows-source-border)";
		if (n.type === "funnel") {
			const d = n.data as FunnelNodeData;
			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" ? "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 ─────────────────────────

	private getPortDefs(nodeType: FlowNode["type"]): PortDefinition[] {
		return PORT_DEFS[nodeType] || [];
	}

	private getPortPosition(node: FlowNode, portKind: PortKind, side?: "left" | "right"): { x: number; y: number } {
		const s = this.getNodeSize(node);
		let def: PortDefinition | undefined;
		if (side) {
			def = this.getPortDefs(node.type).find((p) => p.kind === portKind && p.side === side);
		}
		if (!def) {
			def = this.getPortDefs(node.type).find((p) => p.kind === portKind && (!side || !p.side));
		}
		if (!def) {
			// Fallback: pick first matching kind
			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 at max threshold line
		if (node.type === "funnel" && portKind === "overflow" && def.side) {
			const d = node.data as FunnelNodeData;
			const h = s.h;
			const zoneTop = 36, zoneBot = h - 6, zoneH = zoneBot - zoneTop;
			const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
			const maxLineY = zoneTop + zoneH * (1 - maxFrac);
			return { x: node.position.x + s.w * def.xFrac, y: node.position.y + maxLineY };
		}

		return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac };
	}

	/** Pick the overflow side port closest to a target node */
	private getOverflowSideForTarget(fromNode: FlowNode, toNode: FlowNode): "left" | "right" {
		const toCenter = toNode.position.x + this.getNodeSize(toNode).w / 2;
		const fromCenter = fromNode.position.x + this.getNodeSize(fromNode).w / 2;
		return toCenter < fromCenter ? "left" : "right";
	}

	private renderPortsSvg(n: FlowNode): string {
		const s = this.getNodeSize(n);
		const defs = this.getPortDefs(n.type);
		return defs.map((p) => {
			const cx = s.w * p.xFrac;
			const cy = s.h * p.yFrac;
			let arrow: string;
			const sideAttr = p.side ? ` data-port-side="${p.side}"` : "";
			if (p.side) {
				// Side port: horizontal arrow
				if (p.side === "left") {
					arrow = `<path class="port-arrow" d="M ${cx - 4} ${cy - 3} l -4 3 l 4 3" style="fill:${p.color}" opacity="0.7"/>`;
				} else {
					arrow = `<path class="port-arrow" d="M ${cx + 4} ${cy - 3} l 4 3 l -4 3" style="fill:${p.color}" opacity="0.7"/>`;
				}
			} else if (p.dir === "out") {
				arrow = `<path class="port-arrow" d="M ${cx - 3} ${cy + (p.yFrac === 0 ? -8 : 4)} l 3 4 l 3 -4" style="fill:${p.color}" opacity="0.7"/>`;
			} else {
				arrow = `<path class="port-arrow" d="M ${cx - 3} ${cy + (p.yFrac === 0 ? -4 : 8)} l 3 -4 l 3 4" style="fill:${p.color}" opacity="0.7"/>`;
			}
			return `<g class="port-group" data-port-kind="${p.kind}" data-port-dir="${p.dir}" data-node-id="${n.id}"${sideAttr}>
				<circle class="port-hit" cx="${cx}" cy="${cy}" r="14" fill="transparent"/>
				<circle class="port-dot" cx="${cx}" cy="${cy}" r="7" style="fill:${p.color};color:${p.color}" stroke="white" stroke-width="2"/>
				${arrow}
			</g>`;
		}).join("");
	}

	private enterWiring(nodeId: string, portKind: PortKind, portSide?: "left" | "right") {
		this.wiringActive = true;
		this.wiringSourceNodeId = nodeId;
		this.wiringSourcePortKind = portKind;
		this.wiringSourcePortSide = portSide || null;
		this.wiringDragging = false;
		const svg = this.shadow.getElementById("flow-canvas");
		if (svg) svg.classList.add("wiring");
		this.applyWiringClasses();
	}

	private cancelWiring() {
		this.wiringActive = false;
		this.wiringSourceNodeId = null;
		this.wiringSourcePortKind = null;
		this.wiringSourcePortSide = null;
		this.wiringDragging = false;
		const svg = this.shadow.getElementById("flow-canvas");
		if (svg) svg.classList.remove("wiring");
		const wireLayer = this.shadow.getElementById("wire-layer");
		if (wireLayer) wireLayer.innerHTML = "";
		this.clearWiringClasses();
	}

	private applyWiringClasses() {
		const nodeLayer = this.shadow.getElementById("node-layer");
		if (!nodeLayer || !this.wiringSourceNodeId || !this.wiringSourcePortKind) return;

		const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId);
		if (!sourceNode) return;

		const sourceDef = this.getPortDefs(sourceNode.type).find((p) => p.kind === this.wiringSourcePortKind);
		const connectsTo = sourceDef?.connectsTo || [];

		nodeLayer.querySelectorAll(".port-group").forEach((g) => {
			const el = g as SVGGElement;
			const nid = el.dataset.nodeId!;
			const pk = el.dataset.portKind as PortKind;
			const pd = el.dataset.portDir!;

			if (nid === this.wiringSourceNodeId && pk === this.wiringSourcePortKind) {
				el.classList.add("port-group--wiring-source");
			} else if (pd === "in" && connectsTo.includes(pk) && nid !== this.wiringSourceNodeId && !this.allocationExists(this.wiringSourceNodeId!, nid, this.wiringSourcePortKind!)) {
				el.classList.add("port-group--wiring-target");
			} else {
				el.classList.add("port-group--wiring-dimmed");
			}
		});
	}

	private clearWiringClasses() {
		const nodeLayer = this.shadow.getElementById("node-layer");
		if (!nodeLayer) return;
		nodeLayer.querySelectorAll(".port-group").forEach((g) => {
			g.classList.remove("port-group--wiring-source", "port-group--wiring-target", "port-group--wiring-dimmed");
		});
	}

	private completeWiring(targetNodeId: string) {
		if (!this.wiringSourceNodeId || !this.wiringSourcePortKind) return;

		const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId);
		const targetNode = this.nodes.find((n) => n.id === targetNodeId);
		if (!sourceNode || !targetNode) { this.cancelWiring(); return; }

		// Determine allocation type and color
		const portKind = this.wiringSourcePortKind;
		if (sourceNode.type === "source" && portKind === "outflow") {
			const d = sourceNode.data as SourceNodeData;
			const color = SPENDING_COLORS[d.targetAllocations.length % SPENDING_COLORS.length] || "#10b981";
			d.targetAllocations.push({ targetId: targetNodeId, percentage: 0, color });
			this.normalizeAllocations(d.targetAllocations);
		} else if (sourceNode.type === "funnel" && portKind === "overflow") {
			const d = sourceNode.data as FunnelNodeData;
			const color = OVERFLOW_COLORS[d.overflowAllocations.length % OVERFLOW_COLORS.length] || "#f59e0b";
			d.overflowAllocations.push({ targetId: targetNodeId, percentage: 0, color });
			this.normalizeAllocations(d.overflowAllocations);
		} else if (sourceNode.type === "funnel" && portKind === "spending") {
			const d = sourceNode.data as FunnelNodeData;
			const color = SPENDING_COLORS[d.spendingAllocations.length % SPENDING_COLORS.length] || "#8b5cf6";
			d.spendingAllocations.push({ targetId: targetNodeId, percentage: 0, color });
			this.normalizeAllocations(d.spendingAllocations);
		} else if (sourceNode.type === "outcome" && portKind === "overflow") {
			const d = sourceNode.data as OutcomeNodeData;
			if (!d.overflowAllocations) d.overflowAllocations = [];
			const color = OVERFLOW_COLORS[d.overflowAllocations.length % OVERFLOW_COLORS.length] || "#f59e0b";
			d.overflowAllocations.push({ targetId: targetNodeId, percentage: 0, color });
			this.normalizeAllocations(d.overflowAllocations);
		}

		this.cancelWiring();
		this.drawCanvasContent();
		this.openEditor(this.wiringSourceNodeId || sourceNode.id);
		this.scheduleSave();
	}

	private normalizeAllocations(allocs: { targetId: string; percentage: number; color: string }[]) {
		if (allocs.length === 0) return;
		const equal = Math.floor(100 / allocs.length);
		const remainder = 100 - equal * allocs.length;
		allocs.forEach((a, i) => { a.percentage = equal + (i === 0 ? remainder : 0); });
	}

	private allocationExists(fromId: string, toId: string, portKind: PortKind): boolean {
		const node = this.nodes.find((n) => n.id === fromId);
		if (!node) return false;
		if (node.type === "source" && portKind === "outflow") {
			return (node.data as SourceNodeData).targetAllocations.some((a) => a.targetId === toId);
		}
		if (node.type === "funnel" && portKind === "overflow") {
			return (node.data as FunnelNodeData).overflowAllocations.some((a) => a.targetId === toId);
		}
		if (node.type === "funnel" && portKind === "spending") {
			return (node.data as FunnelNodeData).spendingAllocations.some((a) => a.targetId === toId);
		}
		if (node.type === "outcome" && portKind === "overflow") {
			return ((node.data as OutcomeNodeData).overflowAllocations || []).some((a) => a.targetId === toId);
		}
		return false;
	}

	private updateWiringTempLine() {
		const wireLayer = this.shadow.getElementById("wire-layer");
		if (!wireLayer || !this.wiringSourceNodeId || !this.wiringSourcePortKind) return;

		const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId);
		if (!sourceNode) return;
		const { x: x1, y: y1 } = this.getPortPosition(sourceNode, this.wiringSourcePortKind, this.wiringSourcePortSide || undefined);

		const svg = this.shadow.getElementById("flow-canvas") as SVGSVGElement | null;
		if (!svg) return;
		const rect = svg.getBoundingClientRect();
		const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom;
		const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom;

		let tempPath: string;
		if (this.wiringSourcePortSide) {
			// Side port: curve outward horizontally first
			const outwardX = this.wiringSourcePortSide === "left" ? x1 - 60 : x1 + 60;
			tempPath = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`;
		} else {
			const cy1 = y1 + (y2 - y1) * 0.4;
			const cy2 = y1 + (y2 - y1) * 0.6;
			tempPath = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`;
		}
		wireLayer.innerHTML = `<path class="wiring-temp-path" d="${tempPath}"/>`;
	}

	// ─── Node position update (direct DOM, no re-render) ──

	private updateNodePosition(n: FlowNode) {
		const nodeLayer = this.shadow.getElementById("node-layer");
		if (!nodeLayer) return;
		const g = nodeLayer.querySelector(`[data-node-id="${n.id}"]`) as SVGGElement | null;
		if (g) g.setAttribute("transform", `translate(${n.position.x},${n.position.y})`);
	}

	// ─── Allocation adjustment ────────────────────────────

	private handleAdjustAllocation(fromId: string, toId: string, allocType: string, delta: number) {
		const node = this.nodes.find((n) => n.id === fromId);
		if (!node) return;

		let allocs: { targetId: string; percentage: number; color: string }[];
		if (allocType === "source") {
			allocs = (node.data as SourceNodeData).targetAllocations;
		} else if (allocType === "overflow") {
			if (node.type === "outcome") {
				allocs = (node.data as OutcomeNodeData).overflowAllocations || [];
			} else {
				allocs = (node.data as FunnelNodeData).overflowAllocations;
			}
		} else {
			allocs = (node.data as FunnelNodeData).spendingAllocations;
		}

		const idx = allocs.findIndex((a) => a.targetId === toId);
		if (idx < 0) return;

		const newPct = Math.max(1, Math.min(99, allocs[idx].percentage + delta));
		const oldPct = allocs[idx].percentage;
		const diff = newPct - oldPct;
		allocs[idx].percentage = newPct;

		// Proportionally rebalance siblings
		const siblings = allocs.filter((_, i) => i !== idx);
		const sibTotal = siblings.reduce((s, a) => s + a.percentage, 0);
		if (sibTotal > 0) {
			for (const sib of siblings) {
				sib.percentage = Math.max(1, Math.round(sib.percentage - diff * (sib.percentage / sibTotal)));
			}
		}

		// Normalize to exactly 100
		const total = allocs.reduce((s, a) => s + a.percentage, 0);
		if (total !== 100 && allocs.length > 1) {
			const last = allocs.find((_, i) => i !== idx) || allocs[allocs.length - 1];
			last.percentage += 100 - total;
			last.percentage = Math.max(1, last.percentage);
		}

		this.redrawEdges();
		this.refreshEditorIfOpen(fromId);
		this.scheduleSave();
	}

	// ─── Editor panel ─────────────────────────────────────

	private openEditor(nodeId: string) {
		this.editingNodeId = nodeId;
		const panel = this.shadow.getElementById("editor-panel");
		if (!panel) return;
		const node = this.nodes.find((n) => n.id === nodeId);
		if (!node) return;

		let content = `<div class="editor-header">
			<span class="editor-title">${this.esc((node.data as any).label || node.type)}</span>
			<button class="editor-close" data-editor-action="close">&times;</button>
		</div>`;

		if (node.type === "source") content += this.renderSourceEditor(node);
		else if (node.type === "funnel") content += this.renderFunnelEditor(node);
		else content += this.renderOutcomeEditor(node);

		content += `<div class="editor-section">
			<button class="editor-btn editor-btn--danger" data-editor-action="delete">Delete Node</button>
		</div>`;

		panel.innerHTML = content;
		panel.classList.add("open");
		this.attachEditorListeners(panel, node);
	}

	private closeEditor() {
		this.editingNodeId = null;
		const panel = this.shadow.getElementById("editor-panel");
		if (panel) { panel.classList.remove("open"); panel.innerHTML = ""; }
	}

	// ─── Inline config panel ─────────────────────────────

	private enterInlineEdit(nodeId: string) {
		const clickedNode = this.nodes.find((n) => n.id === nodeId);
		if (clickedNode?.type === "source") {
			this.openSourcePurchaseModal(nodeId);
			return;
		}
		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;

		const nodeLayer = this.shadow.getElementById("node-layer");
		const g = nodeLayer?.querySelector(`[data-node-id="${nodeId}"]`) 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");

		// Funnels: drag handles instead of config panel
		if (node.type === "funnel") {
			const d = node.data as FunnelNodeData;
			const outflow = d.desiredOutflow || 0;
			const outflowRatio = Math.min(1, outflow / 10000);
			const valveInset = 0.30 - outflowRatio * 0.18;
			const valveInsetPx = Math.round(s.w * valveInset);
			const drainWidth = s.w - 2 * valveInsetPx;

			overlay.innerHTML = `
				<rect class="valve-drag-handle" x="${valveInsetPx - 8}" y="${s.h - 16}" width="${drainWidth + 16}" height="18" rx="5"
					style="fill:var(--rflows-label-spending);cursor:ew-resize;opacity:0.85;stroke:white;stroke-width:1.5"/>
				<text class="valve-drag-label" x="${s.w / 2}" y="${s.h - 4}" text-anchor="middle" fill="white" font-size="11" font-weight="600" pointer-events="none">
					◁ ${this.formatDollar(outflow)}/mo ▷
				</text>
				<rect class="height-drag-handle" x="${s.w / 2 - 28}" y="${s.h + 22}" width="56" height="12" rx="5"
					style="fill:var(--rs-border-strong);cursor:ns-resize;opacity:0.6;stroke:var(--rs-text-muted);stroke-width:1"/>
				<text class="height-drag-label" x="${s.w / 2}" y="${s.h + 31}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9" font-weight="500" pointer-events="none">⇕ capacity</text>`;

			g.appendChild(overlay);
			this.attachFunnelDragListeners(overlay, node, s);

			// Click-outside handler
			const clickOutsideHandler = (e: Event) => {
				const target = e.target as Element;
				if (!target.closest(`[data-node-id="${node.id}"]`)) {
					this.exitInlineEdit();
					this.shadow.removeEventListener("pointerdown", clickOutsideHandler, true);
				}
			};
			setTimeout(() => {
				this.shadow.addEventListener("pointerdown", clickOutsideHandler, true);
			}, 100);
			return;
		}

		// Source/outcome: keep config panel — use DOM APIs for proper namespace handling
		const panelW = 280;
		const panelH = 260;
		const panelX = s.w + 12;
		const panelY = 0;

		const fo = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
		fo.setAttribute("x", String(panelX));
		fo.setAttribute("y", String(panelY));
		fo.setAttribute("width", String(panelW));
		fo.setAttribute("height", String(panelH));
		const panelDiv = document.createElement("div");
		panelDiv.className = "inline-config-panel";
		panelDiv.style.height = `${panelH}px`;
		panelDiv.innerHTML = `
			<div class="icp-tabs">
				<button class="icp-tab icp-tab--active" data-icp-tab="config">Config</button>
				<button class="icp-tab" data-icp-tab="analytics">Analytics</button>
				<button class="icp-tab" data-icp-tab="allocations">Alloc</button>
			</div>
			<div class="icp-body">${this.renderInlineConfigContent(node)}</div>
			<div class="icp-toolbar">
				<button class="iet-done" style="background:var(--rflows-btn-done);color:white">Done</button>
				<button class="iet-delete" style="background:var(--rflows-btn-delete);color:white">Delete</button>
				<button class="iet-panel" style="background:var(--rs-border-strong);color:var(--rs-text-primary)">...</button>
			</div>`;
		fo.appendChild(panelDiv);
		overlay.appendChild(fo);

		g.appendChild(overlay);
		this.attachInlineConfigListeners(g, node);
	}

	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 = `
			<div class="icp-field"><label class="icp-label">Label</label>
				<input class="icp-input" data-icp-field="label" value="${this.esc(d.label)}"/></div>
			<div class="icp-field"><label class="icp-label">Flow Rate ($/mo)</label>
				<input class="icp-input" data-icp-field="flowRate" type="number" value="${d.flowRate}"/></div>
			<div class="icp-field"><label class="icp-label">Pay by</label>
				<div class="icp-pay-by">
					<button class="icp-pay-btn ${d.sourceType === "card" ? "icp-pay-btn--active" : ""}" data-icp-source-type="card">💳 Card</button>
					<button class="icp-pay-btn ${d.sourceType === "safe_wallet" ? "icp-pay-btn--active" : ""}" data-icp-source-type="safe_wallet">🔒 Wallet</button>
					<button class="icp-pay-btn ${d.sourceType === "ridentity" ? "icp-pay-btn--active" : ""}" data-icp-source-type="ridentity">👤 EncryptID</button>
				</div></div>`;
		if (d.sourceType === "card") {
			html += `<button class="icp-fund-btn" data-icp-action="fund">Fund with Card</button>`;
		}
		return html;
	}

	private renderFunnelConfigTab(node: FlowNode): string {
		const d = node.data as FunnelNodeData;
		const outflow = d.desiredOutflow || 0;
		return `
			<div class="icp-field"><label class="icp-label">Label</label>
				<input class="icp-input" data-icp-field="label" value="${this.esc(d.label)}"/></div>
			<div class="icp-range-group">
				<span class="icp-range-label">$/mo</span>
				<input class="icp-range" type="range" min="0" max="3000" step="50" value="${outflow}" data-icp-outflow="desiredOutflow"/>
				<span class="icp-range-value">${this.formatDollar(outflow)}</span>
			</div>
			<div class="icp-derived-info">
				<div class="icp-derived-row"><span class="icp-derived-dot" style="background:var(--rflows-status-critical)"></span>Min (1mo): ${this.formatDollar(d.minThreshold)}</div>
				<div class="icp-derived-row"><span class="icp-derived-dot" style="background:var(--rflows-status-sustained)"></span>Sufficient (4mo): ${this.formatDollar(d.sufficientThreshold ?? d.maxThreshold)}</div>
				<div class="icp-derived-row"><span class="icp-derived-dot" style="background:var(--rflows-status-thriving)"></span>Overflow (6mo): ${this.formatDollar(d.maxThreshold)}</div>
			</div>`;
	}

	private renderOutcomeConfigTab(node: FlowNode): string {
		const d = node.data as OutcomeNodeData;
		return `
			<div class="icp-field"><label class="icp-label">Label</label>
				<input class="icp-input" data-icp-field="label" value="${this.esc(d.label)}"/></div>
			<div class="icp-field"><label class="icp-label">Description</label>
				<input class="icp-input" data-icp-field="description" value="${this.esc(d.description || "")}"/></div>
			<div class="icp-field"><label class="icp-label">Funding Target ($)</label>
				<input class="icp-input" data-icp-field="fundingTarget" type="number" value="${d.fundingTarget}"/></div>
			<div class="icp-field"><label class="icp-label">Status</label>
				<select class="icp-select" data-icp-field="status">
					${["not-started", "in-progress", "completed", "blocked"].map((s) => `<option value="${s}" ${d.status === s ? "selected" : ""}>${s}</option>`).join("")}
				</select></div>`;
	}

	// ── 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 `
				<div class="icp-analytics-row">
					<div class="icp-analytics-label"><span>Fill Level</span><span>${fillPct}%</span></div>
					<div class="icp-analytics-bar"><div class="icp-analytics-fill" style="width:${fillPct}%;background:${fillColor}"></div></div>
				</div>
				<div style="margin-bottom:6px">
					<span class="icp-suf-badge icp-suf-badge--${suf}">${suf}</span>
				</div>
				${totalOut > 0 ? `
				<div class="icp-proportion">
					<div class="icp-proportion-ring" style="background:conic-gradient(#10b981 0% ${outflowPct}%, #f59e0b ${outflowPct}% 100%)"></div>
					<div class="icp-proportion-legend">
						<div class="icp-proportion-item"><span class="icp-proportion-dot" style="background:#10b981"></span>Outflow ${outflowPct}%</div>
						<div class="icp-proportion-item"><span class="icp-proportion-dot" style="background:#f59e0b"></span>Overflow ${overflowPct}%</div>
					</div>
				</div>` : ""}
				<div class="icp-stat-row"><span>Current Value</span><span class="icp-stat-value">${this.formatDollar(d.currentValue)}</span></div>
				<div class="icp-stat-row"><span>Peak Value</span><span class="icp-stat-value">${this.formatDollar(stats?.peakValue || d.currentValue)}</span></div>
				<div class="icp-stat-row"><span>Avg Fill</span><span class="icp-stat-value">${this.formatDollar(stats?.avgFillLevel || d.currentValue)}</span></div>
				<div class="icp-stat-row"><span>Total Inflow</span><span class="icp-stat-value">${this.formatDollar(stats?.totalInflow || 0)}</span></div>`;
		}

		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 `
				<div class="icp-analytics-row">
					<div class="icp-analytics-label"><span>Funding Progress</span><span>${progressPct}%</span></div>
					<div class="icp-analytics-bar"><div class="icp-analytics-fill" style="width:${progressPct}%;background:#10b981"></div></div>
				</div>
				<div class="icp-stat-row"><span>Received</span><span class="icp-stat-value">${this.formatDollar(d.fundingReceived)}</span></div>
				<div class="icp-stat-row"><span>Target</span><span class="icp-stat-value">${this.formatDollar(d.fundingTarget)}</span></div>
				${phasesTotal > 0 ? `<div class="icp-stat-row"><span>Phases</span><span class="icp-stat-value">${phasesAchieved} / ${phasesTotal}</span></div>` : ""}
				<div class="icp-stat-row"><span>Total Inflow</span><span class="icp-stat-value">${this.formatDollar(stats?.totalInflow || 0)}</span></div>`;
		}

		// Source
		const d = node.data as SourceNodeData;
		return `
			<div class="icp-stat-row"><span>Flow Rate</span><span class="icp-stat-value">${this.formatDollar(d.flowRate)}/mo</span></div>
			<div class="icp-stat-row"><span>Total Dispensed</span><span class="icp-stat-value">${this.formatDollar(stats?.totalOutflow || 0)}</span></div>
			${d.targetAllocations.length > 0 ? `<div style="margin-top:8px;font-size:10px;color:var(--rs-text-muted);text-transform:uppercase;font-weight:600;margin-bottom:4px">Allocation Breakdown</div>
			${d.targetAllocations.map((a) => `<div class="icp-alloc-row">
				<span class="icp-alloc-dot" style="background:${a.color}"></span>
				<span style="flex:1">${this.esc(this.getNodeLabel(a.targetId))}</span>
				<span style="font-weight:600">${a.percentage}%</span>
			</div>`).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 = `<div style="font-size:10px;color:var(--rs-text-muted);text-transform:uppercase;font-weight:600;margin-bottom:4px">${title}</div>`;
			for (const a of allocs) {
				html += `<div class="icp-alloc-row">
					<span class="icp-alloc-dot" style="background:${a.color}"></span>
					<span style="flex:1">${this.esc(this.getNodeLabel(a.targetId))}</span>
					<span style="font-weight:600">${a.percentage}%</span>
				</div>`;
			}
			return html;
		};

		if (node.type === "source") {
			const d = node.data as SourceNodeData;
			const html = renderRows("Target Allocations", d.targetAllocations);
			return html || '<div class="icp-empty">No allocations configured</div>';
		}
		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 || '<div class="icp-empty">No allocations configured</div>';
		}
		const od = node.data as OutcomeNodeData;
		const html = renderRows("Overflow Allocations", od.overflowAllocations || []);
		return html || '<div class="icp-empty">No allocations configured</div>';
	}

	// ── Funnel drag handles (valve width + tank height) ──

	private attachFunnelDragListeners(overlay: Element, node: FlowNode, s: { w: number; h: number }) {
		const valveHandle = overlay.querySelector(".valve-drag-handle");
		const heightHandle = overlay.querySelector(".height-drag-handle");

		// Valve drag (horizontal → desiredOutflow)
		if (valveHandle) {
			valveHandle.addEventListener("pointerdown", (e: Event) => {
				const pe = e as PointerEvent;
				pe.stopPropagation();
				pe.preventDefault();
				const startX = pe.clientX;
				const fd = node.data as FunnelNodeData;
				const startOutflow = fd.desiredOutflow || 0;
				(valveHandle as Element).setPointerCapture(pe.pointerId);

				const onMove = (ev: Event) => {
					const me = ev as PointerEvent;
					const deltaX = (me.clientX - startX) / this.canvasZoom;
					let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 10000) / 50) * 50;
					newOutflow = Math.max(0, Math.min(10000, newOutflow));
					fd.desiredOutflow = newOutflow;
					fd.minThreshold = newOutflow;
					fd.maxThreshold = newOutflow * 6;
					if (fd.maxCapacity < fd.maxThreshold * 1.5) {
						fd.maxCapacity = Math.round(fd.maxThreshold * 1.5);
					}
					// Update label text only during drag
					const label = overlay.querySelector(".valve-drag-label");
					if (label) label.textContent = `◁ ${this.formatDollar(newOutflow)}/mo ▷`;
				};

				const onUp = () => {
					valveHandle.removeEventListener("pointermove", onMove);
					valveHandle.removeEventListener("pointerup", onUp);
					valveHandle.removeEventListener("lostpointercapture", onUp);
					// Full redraw with new shape
					this.drawCanvasContent();
					this.redrawEdges();
					this.enterInlineEdit(node.id);
					this.scheduleSave();
				};

				valveHandle.addEventListener("pointermove", onMove);
				valveHandle.addEventListener("pointerup", onUp);
				valveHandle.addEventListener("lostpointercapture", onUp);
			});
		}

		// Height drag (vertical → maxCapacity)
		if (heightHandle) {
			heightHandle.addEventListener("pointerdown", (e: Event) => {
				const pe = e as PointerEvent;
				pe.stopPropagation();
				pe.preventDefault();
				const startY = pe.clientY;
				const fd = node.data as FunnelNodeData;
				const startCapacity = fd.maxCapacity || 9000;
				(heightHandle as Element).setPointerCapture(pe.pointerId);

				const onMove = (ev: Event) => {
					const me = ev as PointerEvent;
					const deltaY = (me.clientY - startY) / this.canvasZoom;
					// Down = more capacity, up = less
					let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500;
					newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(100000, newCapacity));
					fd.maxCapacity = newCapacity;
					// Update label
					const label = overlay.querySelector(".height-drag-label");
					if (label) label.textContent = `⇕ ${this.formatDollar(newCapacity)}`;
				};

				const onUp = () => {
					heightHandle.removeEventListener("pointermove", onMove);
					heightHandle.removeEventListener("pointerup", onUp);
					heightHandle.removeEventListener("lostpointercapture", onUp);
					this.drawCanvasContent();
					this.redrawEdges();
					this.enterInlineEdit(node.id);
					this.scheduleSave();
				};

				heightHandle.addEventListener("pointermove", onMove);
				heightHandle.addEventListener("pointerup", onUp);
				heightHandle.addEventListener("lostpointercapture", onUp);
			});
		}
	}

	// ── 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 = 36;
		const zoneBot = s.h - 6;
		const zoneH = zoneBot - zoneTop;

		const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [
			{ key: "minThreshold", value: d.minThreshold, color: "var(--rflows-status-critical)", label: "Min" },
			{ key: "maxThreshold", value: d.maxThreshold, color: "var(--rflows-status-overflow)", label: "Max" },
		];

		for (const t of thresholds) {
			const frac = t.value / (d.maxCapacity || 1);
			const markerY = zoneTop + zoneH * (1 - frac);
			overlay.innerHTML += `
				<line class="threshold-marker" x1="8" x2="${s.w - 8}" y1="${markerY}" y2="${markerY}" style="stroke:${t.color}" stroke-width="2" stroke-dasharray="4 2"/>
				<rect class="threshold-handle" x="${s.w - 56}" y="${markerY - 9}" width="52" height="18" rx="4" style="fill:${t.color};cursor:ns-resize" data-threshold="${t.key}"/>
				<text x="${s.w - 30}" y="${markerY + 4}" fill="white" font-size="9" text-anchor="middle" pointer-events="none">${t.label} ${this.formatDollar(t.value)}</text>`;
		}
	}

	// ── Inline config listeners ──

	private attachInlineConfigListeners(g: SVGGElement, node: FlowNode) {
		const overlay = g.querySelector(".inline-edit-overlay");
		if (!overlay) return;

		// Get the HTML panel inside foreignObject for reliable cross-namespace queries
		const htmlPanel = overlay.querySelector("foreignObject")?.querySelector(".inline-config-panel") as HTMLElement | null;
		const queryRoot = htmlPanel || overlay;

		// Tab switching
		queryRoot.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;
				queryRoot.querySelectorAll(".icp-tab").forEach((t) => t.classList.remove("icp-tab--active"));
				el.classList.add("icp-tab--active");
				const body = queryRoot.querySelector(".icp-body") as HTMLElement;
				if (body) body.innerHTML = this.renderInlineConfigContent(node);
				this.attachInlineConfigFieldListeners(queryRoot as Element, node);
			});
		});

		// Field listeners
		this.attachInlineConfigFieldListeners(queryRoot, node);

		// Threshold drag handles (funnel — on SVG overlay, not HTML panel)
		this.attachThresholdDragListeners(overlay, node);

		// Done button
		queryRoot.querySelector(".iet-done")?.addEventListener("click", (e: Event) => {
			e.stopPropagation();
			this.exitInlineEdit();
		});

		// Delete button
		queryRoot.querySelector(".iet-delete")?.addEventListener("click", (e: Event) => {
			e.stopPropagation();
			this.deleteNode(node.id);
			this.exitInlineEdit();
		});

		// "..." panel button
		queryRoot.querySelector(".iet-panel")?.addEventListener("click", (e: Event) => {
			e.stopPropagation();
			this.exitInlineEdit();
			this.openEditor(node.id);
		});

		// Fund Now button (source card type)
		const fundBtn = queryRoot.querySelector("[data-icp-action='fund']");
		fundBtn?.addEventListener("click", (e: Event) => {
			e.stopPropagation();
			const sd = node.data as SourceNodeData;
			const flowId = this.flowId || this.getAttribute("flow-id") || "";
			if (sd.walletAddress) {
				this.openOnRampWidget(flowId, sd.walletAddress).catch((err) => console.error("[OnRamp] Error:", err));
			} else {
				this.openUserOnRamp(node.id).catch((err) => console.error("[UserOnRamp] Error:", err));
			}
		});

		// Click-outside handler — listen on shadow root to avoid retargeting issues
		const clickOutsideHandler = (e: Event) => {
			const target = e.target as Element;
			if (!target.closest(`[data-node-id="${node.id}"]`) && !target.closest(".inline-config-panel")) {
				this.exitInlineEdit();
				this.shadow.removeEventListener("pointerdown", clickOutsideHandler, true);
			}
		};
		setTimeout(() => {
			this.shadow.addEventListener("pointerdown", clickOutsideHandler, 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;
				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();
				if (ke.key === "Escape") this.exitInlineEdit();
				ke.stopPropagation();
			});
		});

		// Pay-by buttons (source nodes)
		overlay.querySelectorAll("[data-icp-source-type]").forEach((btn) => {
			btn.addEventListener("click", (e: Event) => {
				e.stopPropagation();
				(node.data as SourceNodeData).sourceType = (btn as HTMLElement).dataset.icpSourceType as any;
				const body = overlay.querySelector(".icp-body") as HTMLElement;
				if (body) body.innerHTML = this.renderInlineConfigContent(node);
				this.attachInlineConfigFieldListeners(overlay, node);
				this.redrawNodeOnly(node);
				this.redrawEdges();
			});
		});

		// 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);
			});
		});

		// Outflow slider — auto-derives all thresholds
		overlay.querySelectorAll("[data-icp-outflow]").forEach((el) => {
			const input = el as HTMLInputElement;
			input.addEventListener("input", () => {
				const val = parseFloat(input.value) || 0;
				const fd = node.data as FunnelNodeData;
				fd.desiredOutflow = val;
				const derived = deriveThresholds(val);
				fd.minThreshold = derived.minThreshold;
				fd.sufficientThreshold = derived.sufficientThreshold;
				fd.maxThreshold = derived.maxThreshold;
				fd.maxCapacity = derived.maxCapacity;
				const valueSpan = input.parentElement?.querySelector(".icp-range-value") as HTMLElement;
				if (valueSpan) valueSpan.textContent = this.formatDollar(val);
				// Update derived info display
				const info = overlay.querySelector(".icp-derived-info");
				if (info) {
					const rows = info.querySelectorAll(".icp-derived-row");
					if (rows[0]) rows[0].innerHTML = `<span class="icp-derived-dot" style="background:var(--rflows-status-critical)"></span>Min (1mo): ${this.formatDollar(derived.minThreshold)}`;
					if (rows[1]) rows[1].innerHTML = `<span class="icp-derived-dot" style="background:var(--rflows-status-sustained)"></span>Sufficient (4mo): ${this.formatDollar(derived.sufficientThreshold)}`;
					if (rows[2]) rows[2].innerHTML = `<span class="icp-derived-dot" style="background:var(--rflows-status-thriving)"></span>Overflow (6mo): ${this.formatDollar(derived.maxThreshold)}`;
				}
				this.redrawNodeOnly(node);
				this.redrawEdges();
				this.redrawThresholdMarkers(node);
				this.scheduleSave();
			});
		});
	}

	private attachThresholdDragListeners(overlay: Element, node: FlowNode) {
		overlay.querySelectorAll(".threshold-handle").forEach((el) => {
			el.addEventListener("pointerdown", (e: Event) => {
				const pe = e as PointerEvent;
				pe.stopPropagation();
				pe.preventDefault();
				const thresholdKey = (el as SVGElement).dataset.threshold!;
				this.inlineEditDragThreshold = thresholdKey;
				this.inlineEditDragStartY = pe.clientY;
				this.inlineEditDragStartValue = (node.data as any)[thresholdKey] || 0;
				(el as Element).setPointerCapture(pe.pointerId);
			});
			el.addEventListener("pointermove", (e: Event) => {
				if (!this.inlineEditDragThreshold) return;
				const pe = e as PointerEvent;
				const d = node.data as FunnelNodeData;
				const s = this.getNodeSize(node);
				const zoneH = s.h - 6 - 36;
				const deltaY = (pe.clientY - this.inlineEditDragStartY) / this.canvasZoom;
				const deltaDollars = -(deltaY / zoneH) * (d.maxCapacity || 1);
				let newVal = this.inlineEditDragStartValue + deltaDollars;
				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);
				this.redrawNodeInlineEdit(node);
			});
			el.addEventListener("pointerup", () => {
				this.inlineEditDragThreshold = null;
			});
		});
	}

	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();
		});
		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) {
		const nodeLayer = this.shadow.getElementById("node-layer");
		if (!nodeLayer) return;
		const g = nodeLayer.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null;
		if (!g) return;
		const satisfaction = this.computeInflowSatisfaction();
		const newSvg = this.renderNodeSvg(node, satisfaction);
		// Detach overlay so it survives the content replacement
		const overlay = g.querySelector(".inline-edit-overlay");
		if (overlay) overlay.remove();
		// Parse new SVG to extract attributes and inner content
		const temp = document.createElementNS("http://www.w3.org/2000/svg", "g");
		temp.innerHTML = newSvg;
		const newG = temp.firstElementChild as SVGGElement;
		if (newG) {
			for (const attr of Array.from(newG.attributes)) {
				g.setAttribute(attr.name, attr.value);
			}
			// Use innerHTML for atomic replacement — avoids blur-event reentrancy
			// that causes "node to be removed is no longer a child" errors
			g.innerHTML = newG.innerHTML;
		}
		// Reattach overlay
		if (overlay) g.appendChild(overlay);
		this.scheduleSave();
	}

	private redrawNodeInlineEdit(node: FlowNode) {
		if (node.type === "source") { this.redrawNodeOnly(node); return; }
		this.drawCanvasContent();
		// Re-enter inline edit to show appropriate handles/panel
		this.enterInlineEdit(node.id);
	}

	private exitInlineEdit() {
		if (!this.inlineEditNodeId) return;
		const nodeLayer = this.shadow.getElementById("node-layer");
		const g = nodeLayer?.querySelector(`[data-node-id="${this.inlineEditNodeId}"]`) as SVGGElement | null;
		if (g) g.querySelector(".inline-edit-overlay")?.remove();
		this.inlineEditNodeId = null;
		this.inlineEditDragThreshold = null;
		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);
	}

	private renderSourceEditor(n: FlowNode): string {
		const d = n.data as SourceNodeData;
		let html = `
		<div class="editor-field"><label class="editor-label">Label</label>
			<input class="editor-input" data-field="label" value="${this.esc(d.label)}"/></div>
		<div class="editor-field"><label class="editor-label">Flow Rate ($/mo)</label>
			<input class="editor-input" data-field="flowRate" type="number" value="${d.flowRate}"/></div>
		<div class="editor-field"><label class="editor-label">Pay by</label>
			<div class="editor-pay-by">
				<button class="editor-pay-btn ${d.sourceType === "card" ? "editor-pay-btn--active" : ""}" data-editor-source-type="card">💳<span>Card</span></button>
				<button class="editor-pay-btn ${d.sourceType === "safe_wallet" ? "editor-pay-btn--active" : ""}" data-editor-source-type="safe_wallet">🔒<span>Wallet</span></button>
				<button class="editor-pay-btn ${d.sourceType === "ridentity" ? "editor-pay-btn--active" : ""}" data-editor-source-type="ridentity">👤<span>EncryptID</span></button>
			</div></div>`;
		if (d.sourceType === "card") {
			html += `<div class="editor-field" style="margin-top:12px">
				<button class="editor-btn fund-card-btn" data-action="fund-with-card"
					style="width:100%;padding:10px;background:var(--rs-primary);color:white;border:none;border-radius:8px;cursor:pointer;font-weight:600">
					Fund with Card
				</button>
			</div>`;
		}
		html += this.renderAllocEditor("Target Allocations", d.targetAllocations);
		return html;
	}

	private renderFunnelEditor(n: FlowNode): string {
		const d = n.data as FunnelNodeData;
		const derived = d.desiredOutflow ? deriveThresholds(d.desiredOutflow) : null;
		return `
		<div class="editor-field"><label class="editor-label">Label</label>
			<input class="editor-input" data-field="label" value="${this.esc(d.label)}"/></div>
		<div class="editor-field"><label class="editor-label">Desired Outflow ($/mo)</label>
			<input class="editor-input" data-field="desiredOutflow" type="number" value="${d.desiredOutflow ?? 0}"/></div>
		<div class="editor-field"><label class="editor-label">Current Value ($)</label>
			<input class="editor-input" data-field="currentValue" type="number" value="${d.currentValue}"/></div>
		<div class="editor-field"><label class="editor-label">Expected Inflow ($/mo)</label>
			<input class="editor-input" data-field="inflowRate" type="number" value="${d.inflowRate}"/></div>
		<div class="editor-section">
			<div class="editor-section-title">Thresholds ${derived ? "(auto-derived from outflow)" : ""}</div>
			<div class="editor-field"><label class="editor-label">Min (1mo)${derived ? ` — ${this.formatDollar(derived.minThreshold)}` : ""}</label>
				<input class="editor-input" data-field="minThreshold" type="number" value="${d.minThreshold}"/></div>
			<div class="editor-field"><label class="editor-label">Sufficient (4mo)${derived ? ` — ${this.formatDollar(derived.sufficientThreshold)}` : ""}</label>
				<input class="editor-input" data-field="sufficientThreshold" type="number" value="${d.sufficientThreshold ?? d.maxThreshold}"/></div>
			<div class="editor-field"><label class="editor-label">Overflow (6mo)${derived ? ` — ${this.formatDollar(derived.maxThreshold)}` : ""}</label>
				<input class="editor-input" data-field="maxThreshold" type="number" value="${d.maxThreshold}"/></div>
			<div class="editor-field"><label class="editor-label">Max Capacity (9mo)${derived ? ` — ${this.formatDollar(derived.maxCapacity)}` : ""}</label>
				<input class="editor-input" data-field="maxCapacity" type="number" value="${d.maxCapacity}"/></div>
		</div>
		${this.renderAllocEditor("Overflow Allocations", d.overflowAllocations)}
		${this.renderAllocEditor("Spending Allocations", d.spendingAllocations)}`;
	}

	private renderOutcomeEditor(n: FlowNode): string {
		const d = n.data as OutcomeNodeData;
		let html = `
		<div class="editor-field"><label class="editor-label">Label</label>
			<input class="editor-input" data-field="label" value="${this.esc(d.label)}"/></div>
		<div class="editor-field"><label class="editor-label">Description</label>
			<input class="editor-input" data-field="description" value="${this.esc(d.description || "")}"/></div>
		<div class="editor-field"><label class="editor-label">Funding Received ($)</label>
			<input class="editor-input" data-field="fundingReceived" type="number" value="${d.fundingReceived}"/></div>
		<div class="editor-field"><label class="editor-label">Funding Target ($)</label>
			<input class="editor-input" data-field="fundingTarget" type="number" value="${d.fundingTarget}"/></div>
		<div class="editor-field"><label class="editor-label">Status</label>
			<select class="editor-select" data-field="status">
				${["not-started", "in-progress", "completed", "blocked"].map((s) => `<option value="${s}" ${d.status === s ? "selected" : ""}>${s}</option>`).join("")}
			</select></div>`;
		if (d.phases && d.phases.length > 0) {
			html += `<div class="editor-section"><div class="editor-section-title">Phases</div>`;
			for (const p of d.phases) {
				const unlocked = d.fundingReceived >= p.fundingThreshold;
				html += `<div style="margin-bottom:6px;padding:6px;background:var(--rs-bg-surface-sunken);border-radius:6px;border-left:3px solid ${unlocked ? "var(--rflows-phase-unlocked)" : "var(--rs-border-strong)"}">
					<div style="font-size:12px;font-weight:600;color:${unlocked ? "var(--rs-success)" : "var(--rs-text-muted)"}">${this.esc(p.name)} — $${p.fundingThreshold.toLocaleString()}</div>
					${p.tasks.map((t) => `<div style="font-size:11px;color:var(--rs-text-secondary);margin-top:2px">${t.completed ? "&#x2705;" : "&#x2B1C;"} ${this.esc(t.label)}</div>`).join("")}
				</div>`;
			}
			html += `</div>`;
		}
		if (d.overflowAllocations && d.overflowAllocations.length > 0) {
			html += this.renderAllocEditor("Overflow Allocations", d.overflowAllocations);
		}
		return html;
	}

	private renderAllocEditor(title: string, allocs: { targetId: string; percentage: number; color: string }[]): string {
		if (!allocs || allocs.length === 0) return "";
		let html = `<div class="editor-section"><div class="editor-section-title">${title}</div>`;
		for (const a of allocs) {
			html += `<div class="editor-alloc-row">
				<span class="editor-alloc-dot" style="background:${a.color}"></span>
				<span style="flex:1">${this.esc(this.getNodeLabel(a.targetId))}</span>
				<span style="font-weight:600;color:var(--rs-text-primary)">${a.percentage}%</span>
			</div>`;
		}
		html += `</div>`;
		return html;
	}

	private async openOnRampWidget(flowId: string, walletAddress: string) {
		console.log("[OnRamp] Opening widget for flow:", flowId, "wallet:", walletAddress);
		// Re-use the user-onramp endpoint — it handles both providers server-side
		this.openUserOnRamp(flowId).catch((err) => console.error("[OnRamp] Error:", err));
	}

	/**
	 * Prompt user for email via a modal dialog.
	 */
	private promptFundDetails(defaultAmount = 2): Promise<{ email: string; amount: number; label: string; provider: string } | null> {
		return new Promise((resolve) => {
			const modal = document.createElement("div");
			modal.style.cssText = `position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;`;
			const inputStyle = `width:100%;padding:10px 12px;border:1px solid var(--rflows-modal-border);border-radius:8px;font-size:14px;box-sizing:border-box;background:var(--rs-bg-surface);color:var(--rs-text-primary)`;

			modal.innerHTML = `
				<div style="background:var(--rs-bg-surface);border-radius:16px;padding:28px;width:400px;max-width:90vw">
					<h3 style="margin:0 0 4px;color:var(--rs-text-primary);font-size:17px">Fund a Flow</h3>
					<p style="margin:0 0 20px;color:var(--rs-text-secondary);font-size:13px">Enter payment details below.</p>
					<label style="display:block;margin-bottom:12px">
						<span style="display:block;margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary)">Amount ($)</span>
						<input id="fund-amount" type="number" min="1" step="0.01" value="${defaultAmount}"
							style="${inputStyle}"/>
					</label>
					<label style="display:block;margin-bottom:12px">
						<span style="display:block;margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary)">Recipient Email</span>
						<input id="fund-email" type="email" placeholder="friend@example.com"
							style="${inputStyle}"/>
					</label>
					<label style="display:block;margin-bottom:20px">
						<span style="display:block;margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary)">Label (optional)</span>
						<input id="fund-label" type="text" placeholder="Coffee Fund"
							style="${inputStyle}"/>
					</label>
					<div style="display:flex;gap:8px">
						<button id="fund-cancel" style="flex:1;padding:10px;border:1px solid var(--rflows-modal-border);border-radius:8px;background:none;color:var(--rs-text-secondary);cursor:pointer">Cancel</button>
						<button id="fund-submit" style="flex:1;padding:10px;border:none;border-radius:8px;background:var(--rs-primary);color:white;font-weight:600;cursor:pointer">Pay with Card</button>
					</div>
				</div>`;
			document.body.appendChild(modal);

			const amountInput = modal.querySelector("#fund-amount") as HTMLInputElement;
			const emailInput = modal.querySelector("#fund-email") as HTMLInputElement;
			const labelInput = modal.querySelector("#fund-label") as HTMLInputElement;
			emailInput.focus();

			const cleanup = (value: { email: string; amount: number; label: string; provider: string } | null) => { modal.remove(); resolve(value); };

			const submit = () => {
				const email = emailInput.value.trim();
				const amount = parseFloat(amountInput.value) || 0;
				if (!email || !email.includes("@")) { emailInput.style.borderColor = "red"; return; }
				if (amount <= 0) { amountInput.style.borderColor = "red"; return; }
				cleanup({ email, amount, label: labelInput.value.trim(), provider: "transak" });
			};

			modal.querySelector("#fund-cancel")!.addEventListener("click", () => cleanup(null));
			modal.addEventListener("click", (e) => { if (e.target === modal) cleanup(null); });
			modal.querySelector("#fund-submit")!.addEventListener("click", submit);
			modal.addEventListener("keydown", (e) => {
				if (e.key === "Enter") submit();
				if (e.key === "Escape") cleanup(null);
			});
		});
	}

	// ─── Source Purchase Modal ─────────────────────────────

	private openSourcePurchaseModal(nodeId: string) {
		if (this.sourceModalNodeId) return; // guard re-entry
		const node = this.nodes.find((n) => n.id === nodeId);
		if (!node || node.type !== "source") return;
		this.sourceModalNodeId = nodeId;
		const sd = node.data as SourceNodeData;

		const valveColors: Record<string, string> = { card: "#3b82f6", safe_wallet: "#10b981", ridentity: "#7c3aed", metamask: "#f6851b", unconfigured: "#64748b" };
		const inputStyle = `width:100%;padding:10px 12px;border:1px solid var(--rflows-modal-border, #334155);border-radius:8px;font-size:14px;box-sizing:border-box;background:var(--rs-bg-surface, #1e293b);color:var(--rs-text-primary, #e2e8f0)`;

		const modal = document.createElement("div");
		modal.className = "source-modal";

		const renderMethodDetail = () => {
			const detailEl = modal.querySelector(".spm-method-detail") as HTMLElement;
			if (!detailEl) return;
			if (sd.sourceType === "card") {
				detailEl.innerHTML = `<button class="spm-action-btn" data-spm-action="fund-card" style="width:100%;padding:10px;border:none;border-radius:8px;background:var(--rs-primary);color:white;font-weight:600;cursor:pointer;margin-top:8px">Fund with Card</button>`;
			} else if (sd.sourceType === "metamask") {
				const addr = sd.walletAddress ? `<div style="margin-top:8px;font-size:12px;color:var(--rs-text-muted, #94a3b8);word-break:break-all">Connected: ${sd.walletAddress}</div>` : "";
				detailEl.innerHTML = `<button class="spm-action-btn" data-spm-action="connect-metamask" style="width:100%;padding:10px;border:none;border-radius:8px;background:#f6851b;color:white;font-weight:600;cursor:pointer;margin-top:8px">${sd.walletAddress ? "Reconnect MetaMask" : "Connect MetaMask"}</button>${addr}`;
			} else if (sd.sourceType === "ridentity") {
				const session = getSession();
				detailEl.innerHTML = `<div style="margin-top:8px;padding:10px;border:1px solid var(--rflows-modal-border, #334155);border-radius:8px;font-size:13px;color:var(--rs-text-secondary, #94a3b8)">${session ? `Linked as <strong style="color:var(--rs-text-primary, #e2e8f0)">${session.claims.username || session.claims.sub}</strong>` : "Not signed in"}</div>`;
			} else {
				detailEl.innerHTML = "";
			}
			// Re-attach detail listeners
			modal.querySelector("[data-spm-action='fund-card']")?.addEventListener("click", (e: Event) => {
				e.stopPropagation();
				this.openUserOnRamp(nodeId).catch((err) => console.error("[UserOnRamp] Error:", err));
			});
			modal.querySelector("[data-spm-action='connect-metamask']")?.addEventListener("click", async (e: Event) => {
				e.stopPropagation();
				await this.connectMetaMask(nodeId);
				renderMethodDetail();
			});
		};

		const updateMethodBtns = () => {
			modal.querySelectorAll(".spm-method-btn").forEach((btn) => {
				const type = (btn as HTMLElement).dataset.spmType || "";
				btn.classList.toggle("spm-method-btn--active", type === sd.sourceType);
			});
		};

		// Build allocation display
		let allocHtml = "";
		if (sd.targetAllocations && sd.targetAllocations.length > 0) {
			const barSegs = sd.targetAllocations.map(a =>
				`<div style="flex:${a.percentage};height:6px;background:${a.color};border-radius:2px"></div>`
			).join("");
			const labels = sd.targetAllocations.map(a =>
				`<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--rs-text-secondary, #94a3b8)"><span style="width:8px;height:8px;border-radius:50%;background:${a.color};flex-shrink:0"></span>${this.esc(this.getNodeLabel(a.targetId))} — ${a.percentage}%</div>`
			).join("");
			allocHtml = `<div style="margin-top:16px">
				<div style="font-size:11px;text-transform:uppercase;font-weight:600;color:var(--rs-text-muted, #64748b);margin-bottom:6px">Allocations</div>
				<div style="display:flex;gap:2px;margin-bottom:8px">${barSegs}</div>
				<div style="display:flex;flex-direction:column;gap:4px">${labels}</div>
			</div>`;
		}

		modal.innerHTML = `
			<div class="spm-backdrop"></div>
			<div class="spm-card">
				<h3 style="margin:0 0 16px;color:var(--rs-text-primary, #e2e8f0);font-size:17px">Configure Source</h3>
				<label style="display:block;margin-bottom:12px">
					<span style="display:block;margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary, #94a3b8)">Label</span>
					<input class="spm-label-input" type="text" value="${this.esc(sd.label)}" style="${inputStyle}"/>
				</label>
				<label style="display:block;margin-bottom:12px">
					<span style="display:block;margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary, #94a3b8)">Monthly Amount ($)</span>
					<input class="spm-amount-input" type="number" min="0" step="50" value="${sd.flowRate}" style="${inputStyle}"/>
				</label>
				<div style="margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary, #94a3b8)">Payment Method</div>
				<div class="spm-method-grid">
					<button class="spm-method-btn ${sd.sourceType === "card" ? "spm-method-btn--active" : ""}" data-spm-type="card">
						<span style="font-size:20px">\u{1F4B3}</span><span>Card</span>
					</button>
					<button class="spm-method-btn ${sd.sourceType === "metamask" ? "spm-method-btn--active" : ""}" data-spm-type="metamask">
						<span style="font-size:20px">\u{1F98A}</span><span>MetaMask</span>
					</button>
					<button class="spm-method-btn ${sd.sourceType === "ridentity" ? "spm-method-btn--active" : ""}" data-spm-type="ridentity">
						<span style="font-size:20px">\u{1F464}</span><span>rIdentity</span>
					</button>
				</div>
				<div class="spm-method-detail"></div>
				${allocHtml}
				<div style="display:flex;gap:8px;margin-top:20px;align-items:center">
					<button class="spm-delete-btn" style="padding:8px 14px;border:1px solid var(--rs-error);border-radius:8px;background:none;color:var(--rs-error);cursor:pointer;font-size:13px">Delete</button>
					<div style="flex:1"></div>
					<button class="spm-close-btn" style="padding:10px 20px;border:none;border-radius:8px;background:var(--rs-primary, #10b981);color:white;font-weight:600;cursor:pointer;font-size:14px">Save &amp; Close</button>
				</div>
			</div>`;

		document.body.appendChild(modal);
		renderMethodDetail();

		// Live field updates
		const labelInput = modal.querySelector(".spm-label-input") as HTMLInputElement;
		const amountInput = modal.querySelector(".spm-amount-input") as HTMLInputElement;

		const applyChanges = () => {
			this.redrawNodeOnly(node);
			this.redrawEdges();
			this.scheduleSave();
		};

		labelInput.addEventListener("input", () => { sd.label = labelInput.value; applyChanges(); });
		amountInput.addEventListener("input", () => { sd.flowRate = parseFloat(amountInput.value) || 0; applyChanges(); });

		// Method selection
		modal.querySelectorAll(".spm-method-btn").forEach((btn) => {
			btn.addEventListener("click", (e: Event) => {
				e.stopPropagation();
				sd.sourceType = ((btn as HTMLElement).dataset.spmType || "unconfigured") as SourceNodeData["sourceType"];
				updateMethodBtns();
				renderMethodDetail();
				applyChanges();
			});
		});

		// Close / Delete
		const closeModal = () => {
			this.sourceModalNodeId = null;
			modal.remove();
		};

		modal.querySelector(".spm-close-btn")!.addEventListener("click", closeModal);
		modal.querySelector(".spm-backdrop")!.addEventListener("click", closeModal);
		modal.addEventListener("keydown", (e: KeyboardEvent) => {
			if (e.key === "Escape") closeModal();
		});

		modal.querySelector(".spm-delete-btn")!.addEventListener("click", () => {
			closeModal();
			this.nodes = this.nodes.filter((nn) => nn.id !== nodeId);
			this.drawCanvasContent();
			this.scheduleSave();
		});

		labelInput.focus();
	}

	private async connectMetaMask(nodeId: string) {
		const node = this.nodes.find((n) => n.id === nodeId);
		if (!node || node.type !== "source") return;
		const sd = node.data as SourceNodeData;
		const ethereum = (window as any).ethereum;
		if (!ethereum) {
			alert("MetaMask not detected. Please install the MetaMask browser extension.");
			return;
		}
		try {
			const accounts: string[] = await ethereum.request({ method: "eth_requestAccounts" });
			const chainId: string = await ethereum.request({ method: "eth_chainId" });
			sd.walletAddress = accounts[0];
			sd.chainId = parseInt(chainId, 16);
			this.redrawNodeOnly(node);
			this.redrawEdges();
			this.scheduleSave();
		} catch (err) {
			console.error("[MetaMask] Connection failed:", err);
		}
	}

	/**
	 * Open Transak on-ramp widget in an iframe modal.
	 */
	private openWidgetModal(url: string) {
		const showClaimMessage = () => {
			document.getElementById("onramp-modal")?.remove();
			const successModal = document.createElement("div");
			successModal.id = "onramp-modal";
			successModal.style.cssText = `position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;`;
			successModal.innerHTML = `
				<div style="position:relative;width:450px;border-radius:16px;overflow:hidden;background:var(--rs-bg-surface);padding:40px;text-align:center">
					<button id="onramp-close" style="position:absolute;top:8px;right:12px;background:none;border:none;color:var(--rs-text-secondary);font-size:24px;cursor:pointer">&times;</button>
					<div style="font-size:48px;margin-bottom:16px">&#9989;</div>
					<h2 style="color:var(--rs-text-primary);margin-bottom:12px;font-size:20px">Payment Complete!</h2>
					<p style="color:var(--rs-text-secondary);font-size:14px;line-height:1.6;margin-bottom:24px">
						Check your email to claim your funds via EncryptID.<br>
						No wallet keys or seed phrases needed.
					</p>
					<div style="background:var(--rs-bg-hover);border-radius:8px;padding:12px 16px;font-size:13px;color:var(--rs-text-secondary)">
						&#128231; A claim link has been sent to your email
					</div>
					<button id="onramp-done" style="margin-top:20px;padding:12px 32px;background:linear-gradient(90deg,#00d4ff,#7c3aed);color:#fff;border:none;border-radius:8px;font-weight:600;font-size:15px;cursor:pointer;width:100%">Done</button>
				</div>`;
			document.body.appendChild(successModal);
			successModal.querySelector("#onramp-close")!.addEventListener("click", () => successModal.remove());
			successModal.querySelector("#onramp-done")!.addEventListener("click", () => successModal.remove());
		};

		const modal = document.createElement("div");
		modal.id = "onramp-modal";
		modal.style.cssText = `position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;`;
		modal.innerHTML = `
			<div style="position:relative;width:450px;height:680px;border-radius:16px;overflow:hidden;background:var(--rs-bg-surface)">
				<button id="onramp-close" style="position:absolute;top:8px;right:12px;z-index:10;
					background:none;border:none;color:white;font-size:24px;cursor:pointer">&times;</button>
				<iframe src="${url}" style="width:100%;height:100%;border:none"
					allow="camera;microphone;payment"
					referrerpolicy="strict-origin-when-cross-origin"></iframe>
			</div>`;

		document.body.appendChild(modal);

		modal.querySelector("#onramp-close")!.addEventListener("click", () => modal.remove());
		modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); });

		const handler = (e: MessageEvent) => {
			if (e.data?.event_id === "TRANSAK_ORDER_SUCCESSFUL") {
				console.log("[OnRamp] Transak order successful:", e.data.data);
				window.removeEventListener("message", handler);
				modal.remove();
				showClaimMessage();
			}
		};
		window.addEventListener("message", handler);
	}

	/**
	 * Full user on-ramp flow: prompt email → create wallet → open Transak widget.
	 * Saves the provisioned wallet address back to the source node.
	 */
	private async openUserOnRamp(nodeId: string) {
		const node = this.nodes.find((n) => n.id === nodeId);
		const fiatAmount = node && node.type === "source" ? (node.data as SourceNodeData).flowRate || 50 : 50;

		const details = await this.promptFundDetails(fiatAmount);
		if (!details) return;

		const base = this.getApiBase();

		const url = `${base}/api/flows/user-onramp`;
		console.log("[UserOnRamp] POST", url);

		try {
			const res = await fetch(url, {
				method: "POST",
				headers: { "Content-Type": "application/json" },
				body: JSON.stringify({ email: details.email, fiatAmount: details.amount, fiatCurrency: "USD", provider: details.provider }),
			});

			const ct = res.headers.get("content-type") || "";
			if (!ct.includes("application/json")) {
				console.error("[UserOnRamp] Expected JSON but got:", ct, "status:", res.status);
				alert(`On-ramp failed: server returned ${res.status} (not JSON). Is the flow-service running?`);
				return;
			}

			if (!res.ok) {
				const err = await res.json().catch(() => ({ error: "Unknown error" }));
				const msg = typeof err.error === 'string' ? err.error : JSON.stringify(err.error) || res.statusText;
				console.error("[UserOnRamp] Server error:", res.status, err);
				alert(`On-ramp failed: ${msg}`);
				return;
			}

			const data = await res.json();
			console.log("[UserOnRamp] Session created:", data.sessionId, "wallet:", data.walletAddress, "provider:", data.provider);

			// Save wallet address and label to source node
			const targetNode = this.nodes.find((n) => n.id === nodeId);
			if (targetNode && targetNode.type === "source") {
				const sd = targetNode.data as SourceNodeData;
				sd.walletAddress = data.walletAddress;
				if (details.label) sd.label = details.label;
				sd.flowRate = details.amount;
				this.drawCanvasContent();
				this.scheduleSave();
			}

			// Open on-ramp widget
			this.openWidgetModal(data.widgetUrl);
		} catch (err) {
			const msg = err instanceof Error ? err.message : String(err);
			console.error("[UserOnRamp] Error:", msg, err);
			alert(`On-ramp failed: ${msg}`);
		}
	}

	/**
	 * Quick Fund: one-click toolbar flow — collects amount + email + label,
	 * calls user-onramp, creates a source node, and opens the on-ramp widget.
	 */
	private async quickFund() {
		const details = await this.promptFundDetails();
		if (!details) return;

		const base = this.getApiBase();
		const url = `${base}/api/flows/user-onramp`;
		console.log("[QuickFund] POST", url);
		try {
			const res = await fetch(url, {
				method: "POST",
				headers: { "Content-Type": "application/json" },
				body: JSON.stringify({ email: details.email, fiatAmount: details.amount, fiatCurrency: "USD", provider: details.provider }),
			});

			const ct = res.headers.get("content-type") || "";
			if (!ct.includes("application/json")) {
				console.error("[QuickFund] Expected JSON but got:", ct, "status:", res.status);
				alert(`Funding failed: server returned ${res.status} (not JSON). Is the flow-service running?`);
				return;
			}

			if (!res.ok) {
				const err = await res.json().catch(() => ({ error: "Unknown error" }));
				const msg = typeof err.error === 'string' ? err.error : JSON.stringify(err.error) || res.statusText;
				console.error("[QuickFund] Server error:", res.status, err);
				alert(`On-ramp failed: ${msg}`);
				return;
			}

			const data = await res.json();
			console.log("[QuickFund] Session created:", data.sessionId, "wallet:", data.walletAddress);

			// Create a source node on the canvas
			const svg = this.shadow.getElementById("flow-canvas") as SVGSVGElement | null;
			const rect = svg?.getBoundingClientRect();
			const cx = rect ? (rect.width / 2 - this.canvasPanX) / this.canvasZoom : 400;
			const cy = rect ? (rect.height / 2 - this.canvasPanY) / this.canvasZoom : 300;
			const id = `source-${Date.now().toString(36)}`;
			const sourceData: SourceNodeData = {
				label: details.label || `$${details.amount} to ${details.email}`,
				flowRate: details.amount,
				sourceType: "card",
				walletAddress: data.walletAddress,
				targetAllocations: [],
			};
			this.nodes.push({ id, type: "source", position: { x: cx - 100, y: cy - 50 }, data: sourceData });
			this.drawCanvasContent();
			this.scheduleSave();

			// Open on-ramp widget
			this.openWidgetModal(data.widgetUrl);
		} catch (err) {
			console.error("[QuickFund] Error:", err);
			alert("Failed to start funding. Check console for details.");
		}
	}

	private attachEditorListeners(panel: HTMLElement, node: FlowNode) {
		// Close button
		panel.querySelector('[data-editor-action="close"]')?.addEventListener("click", () => this.closeEditor());

		// Delete button
		panel.querySelector('[data-editor-action="delete"]')?.addEventListener("click", () => {
			this.deleteNode(node.id);
			this.closeEditor();
		});

		// Prevent keydown from propagating to canvas shortcuts (Delete, Backspace, etc.)
		const inputs = panel.querySelectorAll(".editor-input, .editor-select");
		inputs.forEach((input) => {
			input.addEventListener("keydown", (e) => e.stopPropagation());
			input.addEventListener("change", () => {
				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", "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();
				this.scheduleSave();
			});
		});

		// Fund with Card button (source nodes with sourceType "card")
		panel.querySelector('[data-action="fund-with-card"]')?.addEventListener("click", () => {
			const flowId = this.flowId || this.getAttribute("flow-id") || "";
			const sourceData = node.data as SourceNodeData;
			if (sourceData.walletAddress) {
				this.openOnRampWidget(flowId, sourceData.walletAddress);
			} else {
				this.openUserOnRamp(node.id);
			}
		});

		// Pay-by buttons (source editor)
		panel.querySelectorAll("[data-editor-source-type]").forEach((btn) => {
			btn.addEventListener("click", () => {
				(node.data as SourceNodeData).sourceType = (btn as HTMLElement).dataset.editorSourceType as any;
				this.drawCanvasContent();
				this.scheduleSave();
				this.openEditor(node.id);
			});
		});
	}

	// ─── Node hover tooltip ──────────────────────────────

	private showNodeTooltip(nodeId: string, e: MouseEvent) {
		const tooltip = this.shadow.getElementById("node-tooltip");
		const container = this.shadow.getElementById("canvas-container");
		if (!tooltip || !container) return;
		const node = this.nodes.find((n) => n.id === nodeId);
		if (!node) return;

		let html = `<div class="flows-node-tooltip__label">${this.esc((node.data as any).label)}</div>`;
		if (node.type === "source") {
			const d = node.data as SourceNodeData;
			html += `<div class="flows-node-tooltip__stat">$${d.flowRate.toLocaleString()}/mo &middot; ${d.sourceType}</div>`;
		} else if (node.type === "funnel") {
			const d = node.data as FunnelNodeData;
			const suf = computeSufficiencyState(d);
			html += `<div class="flows-node-tooltip__stat">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.sufficientThreshold ?? d.maxThreshold).toLocaleString()}</div>`;
			html += `<div class="flows-node-tooltip__stat" style="text-transform:capitalize;color:${suf === "sufficient" || suf === "abundant" ? "var(--rflows-sufficiency-highlight)" : "var(--rs-text-secondary)"}">${suf}</div>`;
		} else {
			const d = node.data as OutcomeNodeData;
			const pct = d.fundingTarget > 0 ? Math.round((d.fundingReceived / d.fundingTarget) * 100) : 0;
			html += `<div class="flows-node-tooltip__stat">$${Math.floor(d.fundingReceived).toLocaleString()} / $${Math.floor(d.fundingTarget).toLocaleString()} (${pct}%)</div>`;
			html += `<div class="flows-node-tooltip__stat" style="text-transform:capitalize">${d.status}</div>`;
		}

		tooltip.innerHTML = html;
		tooltip.style.display = "block";
		const rect = container.getBoundingClientRect();
		tooltip.style.left = `${e.clientX - rect.left + 16}px`;
		tooltip.style.top = `${e.clientY - rect.top - 8}px`;
	}

	private hideNodeTooltip() {
		const tooltip = this.shadow.getElementById("node-tooltip");
		if (tooltip) tooltip.style.display = "none";
	}

	private highlightNodeEdges(nodeId: string) {
		const edgeLayer = this.shadow.getElementById("edge-layer");
		if (!edgeLayer) return;
		edgeLayer.querySelectorAll(".edge-group").forEach((g) => {
			const el = g as SVGGElement;
			const isConnected = el.dataset.from === nodeId || el.dataset.to === nodeId;
			el.classList.toggle("edge-group--highlight", isConnected);
		});
	}

	private unhighlightEdges() {
		const edgeLayer = this.shadow.getElementById("edge-layer");
		if (!edgeLayer) return;
		edgeLayer.querySelectorAll(".edge-group--highlight").forEach((g) => {
			g.classList.remove("edge-group--highlight");
		});
	}

	// ─── Node detail modals ──────────────────────────────

	private closeModal() {
		const m = this.shadow.getElementById("flows-modal");
		if (m) m.remove();
	}

	private openOutcomeModal(nodeId: string) {
		this.closeModal();
		const node = this.nodes.find((n) => n.id === nodeId);
		if (!node || node.type !== "outcome") return;
		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" ? "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";

		let phasesHtml = "";
		if (d.phases && d.phases.length > 0) {
			phasesHtml += `<div class="phase-tier-bar">`;
			for (const p of d.phases) {
				const unlocked = d.fundingReceived >= p.fundingThreshold;
				phasesHtml += `<div class="phase-tier-segment" style="background:${unlocked ? "var(--rflows-phase-unlocked)" : "var(--rs-bg-surface-raised)"}"></div>`;
			}
			phasesHtml += `</div>`;

			for (let i = 0; i < d.phases.length; i++) {
				const p = d.phases[i];
				const unlocked = d.fundingReceived >= p.fundingThreshold;
				const completedTasks = p.tasks.filter((t) => t.completed).length;
				const phasePct = p.fundingThreshold > 0 ? Math.min(100, Math.round((d.fundingReceived / p.fundingThreshold) * 100)) : 0;

				phasesHtml += `<div class="phase-card ${unlocked ? "" : "phase-card--locked"}">
					<div class="phase-header" data-phase-idx="${i}">
						<span style="font-size:14px">${unlocked ? "&#x1F513;" : "&#x1F512;"}</span>
						<span style="flex:1;font-size:13px;font-weight:600;color:${unlocked ? "var(--rs-text-primary)" : "var(--rs-text-muted)"}">${this.esc(p.name)}</span>
						<span style="font-size:11px;color:var(--rs-text-muted)">${completedTasks}/${p.tasks.length}</span>
						<span style="font-size:11px;color:var(--rs-text-muted)">$${p.fundingThreshold.toLocaleString()}</span>
						<span style="font-size:12px;color:var(--rs-text-muted);transition:transform 0.2s" data-phase-chevron="${i}">&#x25B6;</span>
					</div>
					<div class="phase-content" data-phase-content="${i}" style="display:none">
						<div style="margin-bottom:8px">
							<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--rs-text-muted);margin-bottom:4px">
								<span>${Math.min(phasePct, 100)}% funded</span>
								<span>$${Math.floor(Math.min(d.fundingReceived, p.fundingThreshold)).toLocaleString()} / $${p.fundingThreshold.toLocaleString()}</span>
							</div>
							<div class="flows-modal__progress-bar">
								<div class="flows-modal__progress-fill" style="width:${Math.min(phasePct, 100)}%;background:${unlocked ? "var(--rflows-phase-unlocked)" : "var(--rflows-status-inprogress)"}"></div>
							</div>
						</div>
						${p.tasks.map((t, ti) => `
							<div class="phase-task ${t.completed ? "phase-task--done" : ""}">
								<input type="checkbox" data-phase="${i}" data-task="${ti}" ${t.completed ? "checked" : ""} ${!unlocked ? "disabled" : ""}/>
								<span>${this.esc(t.label)}</span>
							</div>
						`).join("")}
						${unlocked ? `<button class="phase-add-btn" data-add-task="${i}">+ Add Task</button>` : ""}
					</div>
				</div>`;
			}

			phasesHtml += `<button class="phase-add-btn" data-action="add-phase" style="width:100%;justify-content:center;padding:8px">+ Add Phase</button>`;
		}

		const backdrop = document.createElement("div");
		backdrop.className = "flows-modal-backdrop";
		backdrop.id = "flows-modal";
		backdrop.innerHTML = `<div class="flows-modal">
			<div class="flows-modal__header">
				<div style="display:flex;align-items:center;gap:10px">
					<span style="display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;background:${statusColor}22;color:${statusColor}">${statusLabel}</span>
					<span style="font-size:16px;font-weight:700;color:var(--rs-text-primary)">${this.esc(d.label)}</span>
				</div>
				<button class="flows-modal__close" data-modal-action="close">&times;</button>
			</div>
			${d.description ? `<div style="font-size:13px;color:var(--rs-text-secondary);line-height:1.6;margin-bottom:16px;padding:10px 12px;background:var(--rs-bg-surface-sunken);border-radius:8px;border-left:3px solid ${statusColor}">${this.esc(d.description)}</div>` : ""}
			<div style="margin-bottom:20px">
				<div style="font-size:28px;font-weight:700;color:var(--rs-text-primary)">$${Math.floor(d.fundingReceived).toLocaleString()}</div>
				<div style="font-size:13px;color:var(--rs-text-muted);margin-top:2px">of $${Math.floor(d.fundingTarget).toLocaleString()} (${Math.round(fillPct)}%)</div>
				<div class="flows-modal__progress-bar" style="margin-top:10px;height:10px">
					<div class="flows-modal__progress-fill" style="width:${fillPct}%;background:${statusColor}"></div>
				</div>
			</div>
			${d.phases && d.phases.length > 0 ? `<div style="margin-bottom:16px">
				<div style="font-size:13px;font-weight:600;color:var(--rs-text-secondary);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:10px">Phases</div>
				${phasesHtml}
			</div>` : ""}
		</div>`;

		this.shadow.appendChild(backdrop);
		this.attachOutcomeModalListeners(backdrop, nodeId);
	}

	private attachOutcomeModalListeners(backdrop: HTMLElement, nodeId: string) {
		const node = this.nodes.find((n) => n.id === nodeId);
		if (!node) return;
		const d = node.data as OutcomeNodeData;

		backdrop.addEventListener("click", (e) => {
			if (e.target === backdrop) this.closeModal();
		});
		backdrop.querySelector('[data-modal-action="close"]')?.addEventListener("click", () => this.closeModal());

		// Phase accordion toggle
		backdrop.querySelectorAll(".phase-header").forEach((header) => {
			header.addEventListener("click", () => {
				const idx = (header as HTMLElement).dataset.phaseIdx;
				const content = backdrop.querySelector(`[data-phase-content="${idx}"]`) as HTMLElement | null;
				const chevron = backdrop.querySelector(`[data-phase-chevron="${idx}"]`) as HTMLElement | null;
				if (content) {
					const isOpen = content.style.display !== "none";
					content.style.display = isOpen ? "none" : "block";
					if (chevron) chevron.style.transform = isOpen ? "rotate(0deg)" : "rotate(90deg)";
				}
			});
		});

		// Task checkbox toggle
		backdrop.querySelectorAll('input[type="checkbox"][data-phase]').forEach((cb) => {
			cb.addEventListener("change", () => {
				const phaseIdx = parseInt((cb as HTMLElement).dataset.phase!, 10);
				const taskIdx = parseInt((cb as HTMLElement).dataset.task!, 10);
				if (d.phases && d.phases[phaseIdx] && d.phases[phaseIdx].tasks[taskIdx]) {
					d.phases[phaseIdx].tasks[taskIdx].completed = (cb as HTMLInputElement).checked;
					const taskRow = (cb as HTMLElement).closest(".phase-task");
					if (taskRow) taskRow.classList.toggle("phase-task--done", (cb as HTMLInputElement).checked);
					this.drawCanvasContent();
				}
			});
		});

		// Add task
		backdrop.querySelectorAll("[data-add-task]").forEach((btn) => {
			btn.addEventListener("click", () => {
				const phaseIdx = parseInt((btn as HTMLElement).dataset.addTask!, 10);
				if (d.phases && d.phases[phaseIdx]) {
					const taskLabel = prompt("Task name:");
					if (taskLabel) {
						d.phases[phaseIdx].tasks.push({ label: taskLabel, completed: false });
						this.openOutcomeModal(nodeId);
						this.drawCanvasContent();
					}
				}
			});
		});

		// Add phase
		backdrop.querySelector('[data-action="add-phase"]')?.addEventListener("click", () => {
			const name = prompt("Phase name:");
			if (name) {
				const threshold = parseFloat(prompt("Funding threshold ($):") || "0") || 0;
				if (!d.phases) d.phases = [];
				d.phases.push({ name, fundingThreshold: threshold, tasks: [] });
				this.openOutcomeModal(nodeId);
				this.drawCanvasContent();
			}
		});
	}

	private openSourceModal(nodeId: string) {
		this.closeModal();
		const node = this.nodes.find((n) => n.id === nodeId);
		if (!node || node.type !== "source") return;
		const d = node.data as SourceNodeData;

		const icons: Record<string, string> = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", metamask: "\u{1F98A}", unconfigured: "\u{2699}" };
		const labels: Record<string, string> = { card: "Card", safe_wallet: "Safe / Wallet", ridentity: "rIdentity", unconfigured: "Configure" };

		let configHtml = "";
		if (d.sourceType === "card") {
			const truncWallet = d.walletAddress
				? `${d.walletAddress.slice(0, 6)}...${d.walletAddress.slice(-4)}`
				: "";
			configHtml = `<div style="margin-top:12px">
				${d.walletAddress ? `<div style="display:flex;align-items:center;gap:6px;margin-bottom:10px;padding:8px 10px;background:var(--rs-bg-surface-sunken);border-radius:8px;font-size:12px">
					<span style="color:var(--rs-success)">&#x2705;</span>
					<span style="color:var(--rs-text-secondary)">Funds linked via EncryptID</span>
					<code style="color:var(--rs-text-primary);font-family:monospace;margin-left:auto">${truncWallet}</code>
				</div>` : ""}
				<div class="editor-field" style="margin-bottom:10px">
					<label class="editor-label">Default Amount ($)</label>
					<input class="editor-input" data-modal-field="flowRate" type="number" value="${d.flowRate}"/>
				</div>
				<button class="editor-btn" data-action="fund-with-card" style="width:100%;padding:10px;background:var(--rflows-btn-fund);color:white;border:none;border-radius:8px;font-weight:600;cursor:pointer">
					&#x1F4B3; Fund with Card
				</button>
			</div>`;
		} else if (d.sourceType === "safe_wallet") {
			configHtml = `<div style="margin-top:12px">
				<div class="editor-field" style="margin-bottom:10px">
					<label class="editor-label">Wallet Address</label>
					<input class="editor-input" data-modal-field="walletAddress" value="${this.esc(d.walletAddress || "")}"/>
				</div>
				<div class="editor-field" style="margin-bottom:10px">
					<label class="editor-label">Safe Address</label>
					<input class="editor-input" data-modal-field="safeAddress" value="${this.esc(d.safeAddress || "")}"/>
				</div>
				<div class="editor-field">
					<label class="editor-label">Chain</label>
					<select class="editor-select" data-modal-field="chainId">
						${[{ id: 1, name: "Ethereum" }, { id: 10, name: "Optimism" }, { id: 8453, name: "Base" }, { id: 100, name: "Gnosis" }]
							.map((c) => `<option value="${c.id}" ${d.chainId === c.id ? "selected" : ""}>${c.name}</option>`).join("")}
					</select>
				</div>
			</div>`;
		} else if (d.sourceType === "ridentity") {
			configHtml = `<div style="text-align:center;padding:16px 0;margin-top:12px">
				<div style="font-size:32px;margin-bottom:8px">&#x1F464;</div>
				<div style="font-size:13px;color:var(--rs-text-secondary);margin-bottom:12px">${isAuthenticated() ? "Connected" : "Not connected"}</div>
				${!isAuthenticated() ? `<button class="editor-btn" data-action="connect-ridentity" style="background:var(--rs-primary);color:white;border:none;padding:8px 20px;border-radius:8px;font-weight:600;cursor:pointer">Connect with EncryptID</button>` : `<div style="font-size:12px;color:var(--rs-success)">&#x2705; ${this.esc(getUsername() || "Connected")}</div>`}
			</div>`;
		}

		const backdrop = document.createElement("div");
		backdrop.className = "flows-modal-backdrop";
		backdrop.id = "flows-modal";
		backdrop.innerHTML = `<div class="flows-modal">
			<div class="flows-modal__header">
				<div style="display:flex;align-items:center;gap:10px">
					<span style="font-size:20px">${icons[d.sourceType] || "&#x1F4B0;"}</span>
					<span style="font-size:16px;font-weight:700;color:var(--rs-text-primary)">${this.esc(d.label)}</span>
				</div>
				<button class="flows-modal__close" data-modal-action="close">&times;</button>
			</div>
			<div style="margin-bottom:16px">
				<div style="font-size:11px;font-weight:600;color:var(--rs-text-secondary);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px">Pay by</div>
				<div class="source-type-grid">
					${["card", "safe_wallet", "ridentity"].map((t) => `
						<button class="source-type-btn ${d.sourceType === t ? "source-type-btn--active" : ""}" data-source-type="${t}">
							<span style="font-size:20px">${icons[t]}</span>
							<span>${labels[t]}</span>
						</button>
					`).join("")}
				</div>
			</div>
			<div class="editor-field" style="margin-bottom:12px">
				<label class="editor-label">Label</label>
				<input class="editor-input" data-modal-field="label" value="${this.esc(d.label)}"/>
			</div>
			<div class="editor-field" style="margin-bottom:12px">
				<label class="editor-label">Flow Rate ($/mo)</label>
				<input class="editor-input" data-modal-field="flowRate" type="number" value="${d.flowRate}"/>
			</div>
			<div id="source-config">${configHtml}</div>
			<div style="display:flex;gap:8px;margin-top:20px;padding-top:16px;border-top:1px solid var(--rflows-modal-border)">
				<button class="editor-btn" data-modal-action="save" style="flex:1;background:var(--rflows-btn-save);color:white;border:none;font-weight:600">Save</button>
				<button class="editor-btn" data-modal-action="close" style="flex:1">Cancel</button>
			</div>
		</div>`;

		this.shadow.appendChild(backdrop);
		this.attachSourceModalListeners(backdrop, nodeId);
	}

	private attachSourceModalListeners(backdrop: HTMLElement, nodeId: string) {
		const node = this.nodes.find((n) => n.id === nodeId);
		if (!node) return;
		const d = node.data as SourceNodeData;

		backdrop.addEventListener("click", (e) => {
			if (e.target === backdrop) this.closeModal();
		});
		backdrop.querySelectorAll('[data-modal-action="close"]').forEach((btn) => {
			btn.addEventListener("click", () => this.closeModal());
		});

		// Source type picker
		backdrop.querySelectorAll("[data-source-type]").forEach((btn) => {
			btn.addEventListener("click", () => {
				d.sourceType = (btn as HTMLElement).dataset.sourceType as SourceNodeData["sourceType"];
				this.openSourceModal(nodeId);
				this.drawCanvasContent();
			});
		});

		// Field changes (live)
		backdrop.querySelectorAll(".editor-input[data-modal-field], .editor-select[data-modal-field]").forEach((input) => {
			input.addEventListener("change", () => {
				const field = (input as HTMLElement).dataset.modalField!;
				const val = (input as HTMLInputElement).value;
				const numFields = ["flowRate", "chainId"];
				(d as any)[field] = numFields.includes(field) ? parseFloat(val) || 0 : val;
				this.drawCanvasContent();
			});
		});

		// Save
		backdrop.querySelector('[data-modal-action="save"]')?.addEventListener("click", () => {
			backdrop.querySelectorAll(".editor-input[data-modal-field], .editor-select[data-modal-field]").forEach((input) => {
				const field = (input as HTMLElement).dataset.modalField!;
				const val = (input as HTMLInputElement).value;
				const numFields = ["flowRate", "chainId"];
				(d as any)[field] = numFields.includes(field) ? parseFloat(val) || 0 : val;
			});
			this.drawCanvasContent();
			this.closeModal();
		});

		// Fund with card
		backdrop.querySelector('[data-action="fund-with-card"]')?.addEventListener("click", () => {
			const flowId = this.flowId || this.getAttribute("flow-id") || "";
			if (d.walletAddress) {
				this.openOnRampWidget(flowId, d.walletAddress);
			} else {
				this.openUserOnRamp(nodeId);
			}
		});

		// Connect with EncryptID
		backdrop.querySelector('[data-action="connect-ridentity"]')?.addEventListener("click", () => {
			window.location.href = "/auth/login?redirect=" + encodeURIComponent(window.location.pathname);
		});
	}

	// ─── Node CRUD ────────────────────────────────────────

	private addNode(type: "source" | "funnel" | "outcome") {
		// Place at center of current viewport
		const svg = this.shadow.getElementById("flow-canvas") as SVGSVGElement | null;
		const rect = svg?.getBoundingClientRect();
		const cx = rect ? (rect.width / 2 - this.canvasPanX) / this.canvasZoom : 400;
		const cy = rect ? (rect.height / 2 - this.canvasPanY) / this.canvasZoom : 300;

		const id = `${type}-${Date.now().toString(36)}`;
		let data: any;
		if (type === "source") {
			const username = getUsername();
			const defaultLabel = username
				? `${username}'s stream to ${this.flowName || "Flow"}`
				: `Stream to ${this.flowName || "Flow"}`;
			data = { label: defaultLabel, flowRate: 1000, sourceType: "card", targetAllocations: [] } as SourceNodeData;
		} else if (type === "funnel") {
			data = {
				label: "New Funnel", currentValue: 0, desiredOutflow: 5000,
				minThreshold: 5000, sufficientThreshold: 15000, maxThreshold: 30000,
				maxCapacity: 45000, inflowRate: 0, dynamicOverflow: false,
				overflowAllocations: [], spendingAllocations: [],
			} as FunnelNodeData;
		} else {
			data = { label: "New Outcome", description: "", fundingReceived: 0, fundingTarget: 10000, status: "not-started", overflowAllocations: [] } as OutcomeNodeData;
		}

		this.nodes.push({ id, type, position: { x: cx - 100, y: cy - 50 }, data });
		this.drawCanvasContent();
		this.selectedNodeId = id;
		this.updateSelectionHighlight();
		this.enterInlineEdit(id);
		this.scheduleSave();
	}

	private deleteNode(nodeId: string) {
		this.nodes = this.nodes.filter((n) => n.id !== nodeId);
		// Clean up allocations pointing to deleted node
		for (const n of this.nodes) {
			if (n.type === "source") {
				const d = n.data as SourceNodeData;
				d.targetAllocations = d.targetAllocations.filter((a) => a.targetId !== nodeId);
			}
			if (n.type === "funnel") {
				const d = n.data as FunnelNodeData;
				d.overflowAllocations = d.overflowAllocations.filter((a) => a.targetId !== nodeId);
				d.spendingAllocations = d.spendingAllocations.filter((a) => a.targetId !== nodeId);
			}
			if (n.type === "outcome") {
				const d = n.data as OutcomeNodeData;
				if (d.overflowAllocations) d.overflowAllocations = d.overflowAllocations.filter((a) => a.targetId !== nodeId);
			}
		}
		if (this.selectedNodeId === nodeId) this.selectedNodeId = null;
		this.drawCanvasContent();
		this.updateSufficiencyBadge();
		this.scheduleSave();
	}

	// ─── Simulation ───────────────────────────────────────

	private toggleSimulation() {
		this.isSimulating = !this.isSimulating;
		const btn = this.shadow.getElementById("sim-btn");
		if (btn) btn.textContent = this.isSimulating ? "Pause" : "Play";

		// Show/hide speed slider and timeline
		const speedContainer = this.shadow.getElementById("sim-speed-container");
		const timelineContainer = this.shadow.getElementById("sim-timeline");
		if (speedContainer) speedContainer.style.display = this.isSimulating ? "flex" : "none";
		if (timelineContainer) timelineContainer.style.display = this.isSimulating ? "flex" : "none";

		if (this.isSimulating) {
			this.simTickCount = 0;
			this.nodeAnalytics.clear();
			this.startSimInterval();
		} else {
			if (this.simInterval) { clearInterval(this.simInterval); this.simInterval = null; }
		}
	}

	private startSimInterval() {
		if (this.simInterval) clearInterval(this.simInterval);
		this.simInterval = setInterval(() => {
			this.simTickCount++;
			this.nodes = computeInflowRates(this.nodes);
			this.nodes = simulateTick(this.nodes);
			this.accumulateNodeAnalytics();
			this.updateCanvasLive();
		}, this.simSpeedMs);
	}

	/** Update canvas nodes and edges without full innerHTML rebuild during simulation */
	private updateCanvasLive() {
		const nodeLayer = this.shadow.getElementById("node-layer");
		if (!nodeLayer) return;

		// Try to patch fill rects in-place for smooth CSS transitions
		let didPatch = false;
		for (const n of this.nodes) {
			if (n.type !== "funnel") continue;
			const d = n.data as FunnelNodeData;
			const s = this.getNodeSize(n);
			const h = s.h;
			const zoneTop = 28;
			const zoneBot = h - 4;
			const zoneH = zoneBot - zoneTop;
			const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1));
			const totalFillH = zoneH * fillPct;
			const fillY = zoneTop + zoneH - totalFillH;
			const fillRect = nodeLayer.querySelector(`.funnel-fill-rect[data-node-id="${n.id}"]`) as SVGRectElement | null;
			if (fillRect) {
				fillRect.setAttribute("y", String(fillY));
				fillRect.setAttribute("height", String(totalFillH));
				didPatch = true;
			}
			// Patch value text
			const threshold = d.sufficientThreshold ?? d.maxThreshold;
			const valText = nodeLayer.querySelector(`.funnel-value-text[data-node-id="${n.id}"]`) as SVGTextElement | null;
			if (valText) {
				valText.textContent = `$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}`;
			}
		}

		// 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 <g>
		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) {
			const pct = (this.simTickCount % 100);
			timelineFill.style.width = `${pct}%`;
		}
	}

	private updateSufficiencyBadge() {
		const score = computeSystemSufficiency(this.nodes);
		const scorePct = Math.round(score * 100);
		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}%`;
			badge.style.color = scoreColor;
		}
	}

	// ─── URL sharing ──────────────────────────────────────

	private shareState() {
		try {
			const LZString = (window as any).LZString;
			if (!LZString) {
				// Fallback: copy JSON directly
				const json = JSON.stringify(this.nodes);
				navigator.clipboard.writeText(window.location.href.split("#")[0] + "#flow=" + btoa(json));
				return;
			}
			const json = JSON.stringify(this.nodes);
			const compressed = LZString.compressToEncodedURIComponent(json);
			const url = window.location.href.split("#")[0] + "#flow=" + compressed;
			history.replaceState(null, "", url);
			navigator.clipboard.writeText(url);
		} catch {
			// Silent fail
		}
	}

	private loadFromHash() {
		try {
			const hash = window.location.hash;
			if (!hash.startsWith("#flow=")) return;
			const payload = hash.slice(6);
			let json: string;
			const LZString = (window as any).LZString;
			if (LZString) {
				json = LZString.decompressFromEncodedURIComponent(payload) || "";
			} else {
				json = atob(payload);
			}
			if (!json) return;
			const nodes = JSON.parse(json) as FlowNode[];
			if (Array.isArray(nodes) && nodes.length > 0) {
				this.nodes = nodes;
				this.drawCanvasContent();
				this.fitView();
			}
		} catch {
			// Invalid hash data — ignore
		}
	}

	// ─── Analytics popout panel ──────────────────────────

	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 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-toolbar-btn--active", this.analyticsOpen);
	}

	private attachAnalyticsListeners() {
		const closeBtn = this.shadow.querySelector("[data-analytics-close]");
		closeBtn?.addEventListener("click", () => this.toggleAnalytics());

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

	// ─── Transactions tab ─────────────────────────────────

	private renderTransactionsTab(): string {
		if (this.isDemo) {
			return `
			<div class="flows-tx-empty">
				<p>Transaction history is not available in demo mode.</p>
			</div>`;
		}

		if (!this.txLoaded) {
			return '<div class="flows-loading">Loading transactions...</div>';
		}

		if (this.transactions.length === 0) {
			return `
			<div class="flows-tx-empty">
				<p>No transactions yet for this flow.</p>
			</div>`;
		}

		return `
		<div class="flows-tx-list">
			${this.transactions.map((tx) => `
				<div class="flows-tx">
					<div class="flows-tx__icon">${tx.type === "deposit" ? "&#x2B06;" : tx.type === "withdraw" ? "&#x2B07;" : "&#x1F504;"}</div>
					<div class="flows-tx__body">
						<div class="flows-tx__desc">${this.esc(tx.description || tx.type)}</div>
						<div class="flows-tx__meta">
							${tx.from ? `From: ${this.esc(tx.from)}` : ""}
							${tx.to ? ` &rarr; ${this.esc(tx.to)}` : ""}
						</div>
					</div>
					<div class="flows-tx__amount ${tx.type === "deposit" ? "flows-tx__amount--positive" : "flows-tx__amount--negative"}">
						${tx.type === "deposit" ? "+" : "-"}$${Math.abs(tx.amount).toLocaleString()}
					</div>
					<div class="flows-tx__time">${this.formatTime(tx.timestamp)}</div>
				</div>
			`).join("")}
		</div>`;
	}

	private formatTime(ts: string): string {
		try {
			const d = new Date(ts);
			const now = new Date();
			const diffMs = now.getTime() - d.getTime();
			const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
			if (diffDays === 0) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
			if (diffDays === 1) return "Yesterday";
			if (diffDays < 7) return `${diffDays}d ago`;
			return d.toLocaleDateString();
		} catch {
			return ts;
		}
	}

	// ─── Flow dropdown & management modal ────────────────

	private toggleFlowDropdown() {
		const menu = this.shadow.getElementById("flow-dropdown-menu");
		if (!menu) return;
		this.flowDropdownOpen = !this.flowDropdownOpen;
		menu.style.display = this.flowDropdownOpen ? "block" : "none";
	}

	private closeFlowDropdown() {
		this.flowDropdownOpen = false;
		const menu = this.shadow.getElementById("flow-dropdown-menu");
		if (menu) menu.style.display = "none";
	}

	private openFlowManager() {
		this.flowManagerOpen = true;
		this.render();
	}

	private closeFlowManager() {
		this.flowManagerOpen = false;
		this.render();
	}

	private attachFlowManagerListeners() {
		const overlay = this.shadow.getElementById("flow-manager-overlay");
		if (!overlay) return;

		// Close button & backdrop click
		overlay.querySelector('[data-mgmt-action="close"]')?.addEventListener("click", () => this.closeFlowManager());
		overlay.addEventListener("click", (e) => {
			if (e.target === overlay) this.closeFlowManager();
		});

		// Row actions
		overlay.querySelectorAll("[data-mgmt-action]").forEach((btn) => {
			const action = (btn as HTMLElement).dataset.mgmtAction;
			const id = (btn as HTMLElement).dataset.mgmtId;
			if (!action || action === "close") return;

			btn.addEventListener("click", (e) => {
				e.stopPropagation();
				if (action === "new") { this.closeFlowManager(); this.createNewFlow(); }
				else if (action === "import") this.importFlowJson();
				else if (action === "rename" && id) this.renameFlowInline(id, btn as HTMLElement);
				else if (action === "duplicate" && id) this.duplicateFlow(id);
				else if (action === "export" && id) this.exportFlowJson(id);
				else if (action === "delete" && id) this.deleteFlowConfirm(id);
			});
		});

		// Click a row to switch to that flow
		overlay.querySelectorAll("[data-mgmt-flow]").forEach((row) => {
			row.addEventListener("dblclick", () => {
				const flowId = (row as HTMLElement).dataset.mgmtFlow;
				if (flowId) { this.closeFlowManager(); this.switchToFlow(flowId); }
			});
		});
	}

	private renameFlowInline(flowId: string, triggerBtn: HTMLElement) {
		const row = triggerBtn.closest("[data-mgmt-flow]");
		const nameDiv = row?.querySelector(".flows-mgmt__row-name");
		if (!nameDiv) return;
		const currentName = nameDiv.textContent?.trim() || '';
		nameDiv.innerHTML = `<input type="text" value="${this.esc(currentName)}" />`;
		const input = nameDiv.querySelector("input") as HTMLInputElement;
		input.focus();
		input.select();

		const commitRename = () => {
			const newName = input.value.trim() || currentName;
			if (this.localFirstClient) {
				this.localFirstClient.renameCanvasFlow(flowId, newName);
			} else {
				const raw = localStorage.getItem(`rflows:local:${flowId}`);
				if (raw) {
					const flow = JSON.parse(raw) as CanvasFlow;
					flow.name = newName;
					flow.updatedAt = Date.now();
					localStorage.setItem(`rflows:local:${flowId}`, JSON.stringify(flow));
				}
			}
			if (flowId === this.currentFlowId) this.flowName = newName;
			nameDiv.textContent = newName;
		};
		input.addEventListener("blur", commitRename);
		input.addEventListener("keydown", (e) => {
			if (e.key === "Enter") { e.preventDefault(); input.blur(); }
			if (e.key === "Escape") { nameDiv.textContent = currentName; }
		});
	}

	private duplicateFlow(flowId: string) {
		let sourceFlow: CanvasFlow | undefined;
		if (this.localFirstClient) {
			sourceFlow = this.localFirstClient.getCanvasFlow(flowId);
		} else {
			const raw = localStorage.getItem(`rflows:local:${flowId}`);
			if (raw) sourceFlow = JSON.parse(raw);
		}
		if (!sourceFlow) return;

		const newId = crypto.randomUUID();
		const now = Date.now();
		const copy: CanvasFlow = {
			id: newId,
			name: `${sourceFlow.name} (Copy)`,
			nodes: sourceFlow.nodes.map((n: any) => ({ ...n, data: { ...n.data } })),
			createdAt: now,
			updatedAt: now,
			createdBy: sourceFlow.createdBy,
		};

		if (this.localFirstClient) {
			this.localFirstClient.saveCanvasFlow(copy);
		} else {
			localStorage.setItem(`rflows:local:${newId}`, JSON.stringify(copy));
			const listRaw = localStorage.getItem('rflows:local:list');
			const list: string[] = listRaw ? JSON.parse(listRaw) : [];
			list.push(newId);
			localStorage.setItem('rflows:local:list', JSON.stringify(list));
		}
		this.closeFlowManager();
		this.switchToFlow(newId);
	}

	private exportFlowJson(flowId: string) {
		let flow: CanvasFlow | undefined;
		if (this.localFirstClient) {
			flow = this.localFirstClient.getCanvasFlow(flowId);
		} else {
			const raw = localStorage.getItem(`rflows:local:${flowId}`);
			if (raw) flow = JSON.parse(raw);
			else if (flowId === 'demo') {
				flow = { id: 'demo', name: 'TBFF Demo Flow', nodes: demoNodes, createdAt: 0, updatedAt: 0, createdBy: null };
			}
		}
		if (!flow) return;

		const blob = new Blob([JSON.stringify(flow.nodes, null, 2)], { type: "application/json" });
		const url = URL.createObjectURL(blob);
		const a = document.createElement("a");
		a.href = url;
		a.download = `${flow.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`;
		a.click();
		URL.revokeObjectURL(url);
	}

	private importFlowJson() {
		const input = document.createElement("input");
		input.type = "file";
		input.accept = ".json";
		input.addEventListener("change", async () => {
			const file = input.files?.[0];
			if (!file) return;
			try {
				const text = await file.text();
				const nodes = JSON.parse(text);
				if (!Array.isArray(nodes) || nodes.length === 0) { alert("Invalid flow JSON: expected a non-empty array of nodes."); return; }
				// Basic validation: each node should have id, type, position, data
				for (const n of nodes) {
					if (!n.id || !n.type || !n.position || !n.data) { alert("Invalid node structure in JSON."); return; }
				}

				const id = crypto.randomUUID();
				const now = Date.now();
				const name = file.name.replace(/\.json$/i, '');
				const flow: CanvasFlow = { id, name, nodes, createdAt: now, updatedAt: now, createdBy: null };

				if (this.localFirstClient) {
					this.localFirstClient.saveCanvasFlow(flow);
				} else {
					localStorage.setItem(`rflows:local:${id}`, JSON.stringify(flow));
					const listRaw = localStorage.getItem('rflows:local:list');
					const list: string[] = listRaw ? JSON.parse(listRaw) : [];
					list.push(id);
					localStorage.setItem('rflows:local:list', JSON.stringify(list));
				}
				this.closeFlowManager();
				this.switchToFlow(id);
			} catch { alert("Failed to parse JSON file."); }
		});
		input.click();
	}

	private deleteFlowConfirm(flowId: string) {
		if (!confirm("Delete this flow? This cannot be undone.")) return;
		if (this.localFirstClient) {
			this.localFirstClient.deleteCanvasFlow(flowId);
		} else {
			localStorage.removeItem(`rflows:local:${flowId}`);
			localStorage.removeItem(`rflows:viewport:${flowId}`);
			const listRaw = localStorage.getItem('rflows:local:list');
			const list: string[] = listRaw ? JSON.parse(listRaw) : [];
			localStorage.setItem('rflows:local:list', JSON.stringify(list.filter(id => id !== flowId)));
		}

		if (flowId === this.currentFlowId) {
			// Switch to another flow or create new
			const remaining = this.getFlowList();
			if (remaining.length > 0) this.switchToFlow(remaining[0].id);
			else this.createNewFlow();
		} else {
			this.closeFlowManager();
			this.openFlowManager(); // refresh list
		}
	}

	// ─── Event listeners ──────────────────────────────────

	private attachListeners() {
		// 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)
		const createBtn = this.shadow.querySelector('[data-action="create-flow"]');
		createBtn?.addEventListener("click", () => this.handleCreateFlow());
	}

	private cleanupCanvas() {
		if (this.simInterval) { clearInterval(this.simInterval); this.simInterval = null; }
		this.isSimulating = false;
		if (this.wiringActive) this.cancelWiring();
		if (this._boundKeyDown) { document.removeEventListener("keydown", this._boundKeyDown); this._boundKeyDown = null; }
	}

	private async handleCreateFlow() {
		const token = getAccessToken();
		if (!token) return;

		const name = prompt("Flow name:");
		if (!name) return;

		this.loading = true;
		this.render();
		try {
			const base = this.getApiBase();
			const res = await fetch(`${base}/api/flows`, {
				method: "POST",
				headers: {
					"Content-Type": "application/json",
					Authorization: `Bearer ${token}`,
				},
				body: JSON.stringify({ name }),
			});
			if (res.ok) {
				const data = await res.json();
				const flowId = data.id || data.flowId;
				// Associate with current space
				if (flowId && this.space) {
					await fetch(`${base}/api/space-flows`, {
						method: "POST",
						headers: {
							"Content-Type": "application/json",
							Authorization: `Bearer ${token}`,
						},
						body: JSON.stringify({ space: this.space, flowId }),
					});
				}
				// Navigate to the new flow
				if (flowId) {
					const detailUrl = this.getApiBase()
						? `${this.getApiBase()}/flow/${encodeURIComponent(flowId)}`
						: `/rflows/flow/${encodeURIComponent(flowId)}`;
					window.location.href = detailUrl;
					return;
				}
			} else {
				const err = await res.json().catch(() => ({}));
				this.error = (err as any).error || `Failed to create flow (${res.status})`;
			}
		} catch {
			this.error = "Failed to create flow";
		}
		this.loading = false;
		this.render();
	}

	// ── Guided Tour ──

	startTour() {
		this._tour.start();
	}

	private esc(s: string): string {
		return s
			.replace(/&/g, "&amp;")
			.replace(/</g, "&lt;")
			.replace(/>/g, "&gt;")
			.replace(/"/g, "&quot;");
	}
}

customElements.define("folk-flows-app", FolkFlowsApp);
