Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-02 19:21:05 -08:00
commit 0c56569a00
2 changed files with 493 additions and 8 deletions

View File

@ -591,6 +591,7 @@ class FolkFundsApp extends HTMLElement {
<button class="funds-canvas-btn" data-canvas-action="zoom-in">+</button>
<button class="funds-canvas-btn" data-canvas-action="zoom-out">&minus;</button>
</div>
<div class="funds-node-tooltip" id="node-tooltip" style="display:none"></div>
</div>`;
}
@ -816,7 +817,33 @@ class FolkFundsApp extends HTMLElement {
const group = (e.target as Element).closest(".flow-node") as SVGGElement | null;
if (!group) return;
const nodeId = group.dataset.nodeId;
if (nodeId) this.openEditor(nodeId);
if (!nodeId) return;
const node = this.nodes.find((n) => n.id === nodeId);
if (!node) return;
if (node.type === "outcome") this.openOutcomeModal(nodeId);
else if (node.type === "source") this.openSourceModal(nodeId);
else this.openEditor(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();
}
});
}
@ -858,6 +885,7 @@ class FolkFundsApp extends HTMLElement {
if (e.key === "Escape") {
if (this.wiringActive) { this.cancelWiring(); return; }
this.closeModal();
this.closeEditor();
}
else if (e.key === " ") { e.preventDefault(); this.toggleSimulation(); }
@ -937,7 +965,7 @@ class FolkFundsApp extends HTMLElement {
<rect x="2" y="${zoneY}" width="${w - 4}" height="${overflowH}" fill="#f59e0b" opacity="0.06" rx="0"/>
<rect x="2" y="${fillY}" width="${w - 4}" height="${totalFillH}" fill="${fillColor}" opacity="0.25"/>
<text x="10" y="22" fill="#e2e8f0" font-size="13" font-weight="600">${this.esc(d.label)}</text>
<text x="${w - 10}" y="22" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500">${statusLabel}</text>
<text x="${w - 10}" y="22" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500" class="${isSufficient ? 'sufficiency-glow' : ''}">${statusLabel}</text>
<text x="${w / 2}" y="${h - 24}" text-anchor="middle" fill="#94a3b8" font-size="11">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}</text>
<rect x="8" y="${h - 10}" width="${w - 16}" height="4" rx="2" fill="#334155"/>
<rect x="8" y="${h - 10}" width="${(w - 16) * fillPct}" height="4" rx="2" fill="${fillColor}"/>
@ -1003,7 +1031,7 @@ class FolkFundsApp extends HTMLElement {
const target = this.nodes.find((t) => t.id === alloc.targetId);
if (!target) continue;
const to = this.getPortPosition(target, "inflow");
const strokeW = Math.max(2, (d.flowRate / maxFlow) * (alloc.percentage / 100) * 8);
const strokeW = Math.max(2, (d.flowRate / maxFlow) * (alloc.percentage / 100) * 12);
html += this.renderEdgePath(
from.x, from.y, to.x, to.y,
alloc.color || "#10b981", strokeW, false,
@ -1019,7 +1047,7 @@ class FolkFundsApp extends HTMLElement {
if (!target) continue;
const from = this.getPortPosition(n, "overflow");
const to = this.getPortPosition(target, "inflow");
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 6);
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 10);
html += this.renderEdgePath(
from.x, from.y, to.x, to.y,
alloc.color || "#f59e0b", strokeW, true,
@ -1032,7 +1060,7 @@ class FolkFundsApp extends HTMLElement {
if (!target) continue;
const from = this.getPortPosition(n, "spending");
const to = this.getPortPosition(target, "inflow");
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 5);
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 8);
html += this.renderEdgePath(
from.x, from.y, to.x, to.y,
alloc.color || "#8b5cf6", strokeW, false,
@ -1049,7 +1077,7 @@ class FolkFundsApp extends HTMLElement {
if (!target) continue;
const from = this.getPortPosition(n, "overflow");
const to = this.getPortPosition(target, "inflow");
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 5);
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 8);
html += this.renderEdgePath(
from.x, from.y, to.x, to.y,
alloc.color || "#f59e0b", strokeW, true,
@ -1070,9 +1098,11 @@ class FolkFundsApp extends HTMLElement {
const cy2 = y1 + (y2 - y1) * 0.6;
const midX = (x1 + x2) / 2;
const midY = (y1 + y2) / 2;
const dash = dashed ? ' stroke-dasharray="6 3"' : "";
const d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`;
const animClass = dashed ? "edge-path-overflow" : "edge-path-animated";
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}">
<path d="M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-opacity="0.7"${dash}/>
<path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW * 2.5}" stroke-opacity="0.12" class="edge-glow"/>
<path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-opacity="0.8" class="${animClass}"/>
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
<rect x="-34" y="-12" width="68" height="24" rx="6" fill="#1e293b" stroke="#475569" stroke-width="1" opacity="0.9"/>
<text x="-14" y="5" fill="${color}" font-size="11" font-weight="600" text-anchor="middle">${pct}%</text>
@ -1578,6 +1608,371 @@ class FolkFundsApp extends HTMLElement {
});
}
// ─── 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="funds-node-tooltip__label">${this.esc((node.data as any).label)}</div>`;
if (node.type === "source") {
const d = node.data as SourceNodeData;
html += `<div class="funds-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="funds-node-tooltip__stat">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.sufficientThreshold ?? d.maxThreshold).toLocaleString()}</div>`;
html += `<div class="funds-node-tooltip__stat" style="text-transform:capitalize;color:${suf === "sufficient" || suf === "abundant" ? "#fbbf24" : "#94a3b8"}">${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="funds-node-tooltip__stat">$${Math.floor(d.fundingReceived).toLocaleString()} / $${Math.floor(d.fundingTarget).toLocaleString()} (${pct}%)</div>`;
html += `<div class="funds-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("funds-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" ? "#10b981"
: d.status === "blocked" ? "#ef4444"
: d.status === "in-progress" ? "#3b82f6" : "#64748b";
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 ? "#10b981" : "#334155"}"></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 ? "#e2e8f0" : "#64748b"}">${this.esc(p.name)}</span>
<span style="font-size:11px;color:#64748b">${completedTasks}/${p.tasks.length}</span>
<span style="font-size:11px;color:#64748b">$${p.fundingThreshold.toLocaleString()}</span>
<span style="font-size:12px;color:#64748b;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:#64748b;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="funds-modal__progress-bar">
<div class="funds-modal__progress-fill" style="width:${Math.min(phasePct, 100)}%;background:${unlocked ? "#10b981" : "#3b82f6"}"></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 = "funds-modal-backdrop";
backdrop.id = "funds-modal";
backdrop.innerHTML = `<div class="funds-modal">
<div class="funds-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:#e2e8f0">${this.esc(d.label)}</span>
</div>
<button class="funds-modal__close" data-modal-action="close">&times;</button>
</div>
${d.description ? `<div style="font-size:13px;color:#94a3b8;line-height:1.6;margin-bottom:16px;padding:10px 12px;background:#0f172a;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:#e2e8f0">$${Math.floor(d.fundingReceived).toLocaleString()}</div>
<div style="font-size:13px;color:#64748b;margin-top:2px">of $${Math.floor(d.fundingTarget).toLocaleString()} (${Math.round(fillPct)}%)</div>
<div class="funds-modal__progress-bar" style="margin-top:10px;height:10px">
<div class="funds-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:#94a3b8;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}", unconfigured: "\u{2699}" };
const labels: Record<string, string> = { card: "Card", safe_wallet: "Safe / Wallet", ridentity: "rIdentity", unconfigured: "Configure" };
let configHtml = "";
if (d.sourceType === "card") {
configHtml = `<div style="margin-top:12px">
<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:#6366f1;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:#94a3b8;margin-bottom:12px">${isAuthenticated() ? "Connected" : "Not connected"}</div>
${!isAuthenticated() ? `<button class="editor-btn" data-action="connect-ridentity" style="background:#6366f1;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:#6ee7b7">&#x2705; ${this.esc(getUsername() || "Connected")}</div>`}
</div>`;
}
const backdrop = document.createElement("div");
backdrop.className = "funds-modal-backdrop";
backdrop.id = "funds-modal";
backdrop.innerHTML = `<div class="funds-modal">
<div class="funds-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:#e2e8f0">${this.esc(d.label)}</span>
</div>
<button class="funds-modal__close" data-modal-action="close">&times;</button>
</div>
<div style="margin-bottom:16px">
<div style="font-size:11px;font-weight:600;color:#94a3b8;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px">Source Type</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 #334155">
<button class="editor-btn" data-modal-action="save" style="flex:1;background:#10b981;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) {
alert("Configure a wallet address first (use rIdentity passkey or enter manually)");
return;
}
this.openTransakWidget(flowId, d.walletAddress);
});
// 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") {

View File

@ -326,6 +326,96 @@
to { stroke-dashoffset: -12; }
}
/* ── Edge flow animation ──────────────────────────────── */
@keyframes streamFlow { to { stroke-dashoffset: -24; } }
.edge-path-animated { stroke-dasharray: 8 4; animation: streamFlow 1s linear infinite; }
.edge-path-overflow { stroke-dasharray: 6 3; animation: streamFlow 0.7s linear infinite; }
.edge-glow { pointer-events: none; }
.edge-group--highlight path:not(.edge-glow) { stroke-opacity: 1 !important; filter: brightness(1.4); }
.edge-group--highlight .edge-glow { stroke-opacity: 0.25 !important; }
/* ── Node detail modals ──────────────────────────────── */
.funds-modal-backdrop {
position: fixed; inset: 0; z-index: 50;
background: rgba(0,0,0,0.6); display: flex;
align-items: center; justify-content: center;
animation: modalFadeIn 0.15s ease-out;
}
@keyframes modalFadeIn { from { opacity: 0; } to { opacity: 1; } }
.funds-modal {
background: #1e293b; border-radius: 16px; padding: 24px;
width: 440px; max-height: 85vh; overflow-y: auto;
border: 1px solid #334155; box-shadow: 0 20px 60px rgba(0,0,0,0.5);
animation: modalSlideIn 0.2s ease-out;
}
@keyframes modalSlideIn { from { transform: translateY(12px); opacity: 0; } to { transform: none; opacity: 1; } }
.funds-modal::-webkit-scrollbar { width: 6px; }
.funds-modal::-webkit-scrollbar-track { background: transparent; }
.funds-modal::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
.funds-modal__header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px;
}
.funds-modal__close {
background: none; border: none; color: #94a3b8; font-size: 24px; cursor: pointer;
padding: 2px 8px; border-radius: 4px; transition: color 0.15s;
}
.funds-modal__close:hover { color: #e2e8f0; }
.funds-modal__progress-bar {
height: 8px; background: #334155; border-radius: 4px; overflow: hidden; margin-top: 8px;
}
.funds-modal__progress-fill { height: 100%; border-radius: 4px; transition: width 0.3s; }
/* Phase accordion */
.phase-tier-bar { display: flex; gap: 1px; height: 8px; border-radius: 4px; overflow: hidden; margin-bottom: 16px; }
.phase-tier-segment { flex: 1; transition: background 0.3s; }
.phase-card { border: 1px solid #334155; border-radius: 10px; overflow: hidden; margin-bottom: 8px; }
.phase-card--locked { opacity: 0.5; }
.phase-header {
padding: 10px 14px; cursor: pointer; display: flex; align-items: center; gap: 8px;
background: #0f172a; transition: background 0.15s;
}
.phase-header:hover { background: #1e293b; }
.phase-content { padding: 8px 14px 14px; border-top: 1px solid #334155; }
.phase-task {
display: flex; align-items: center; gap: 8px; font-size: 13px; color: #94a3b8; padding: 4px 0;
}
.phase-task input[type="checkbox"] { accent-color: #10b981; cursor: pointer; }
.phase-task--done { color: #64748b; text-decoration: line-through; }
.phase-add-btn {
display: flex; align-items: center; gap: 4px; font-size: 12px; color: #64748b;
background: none; border: 1px dashed #334155; border-radius: 6px;
padding: 4px 10px; cursor: pointer; margin-top: 6px; transition: all 0.15s;
}
.phase-add-btn:hover { color: #94a3b8; border-color: #475569; }
/* Source type picker */
.source-type-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; }
.source-type-btn {
display: flex; flex-direction: column; align-items: center; gap: 6px;
padding: 14px 8px; border-radius: 10px; border: 2px solid #334155;
background: #0f172a; color: #94a3b8; cursor: pointer; transition: all 0.15s;
font-size: 12px; font-weight: 500;
}
.source-type-btn:hover { border-color: #475569; background: #1e293b; }
.source-type-btn--active { border-color: #10b981; background: #064e3b; color: #6ee7b7; }
/* Node hover tooltip */
.funds-node-tooltip {
position: absolute; z-index: 30; pointer-events: none;
background: rgba(15,23,42,0.95); border: 1px solid #475569; border-radius: 8px;
padding: 8px 12px; font-size: 12px; color: #e2e8f0;
box-shadow: 0 4px 12px rgba(0,0,0,0.4); white-space: nowrap;
}
.funds-node-tooltip__label { font-weight: 600; margin-bottom: 2px; }
.funds-node-tooltip__stat { color: #94a3b8; font-size: 11px; }
/* Sufficiency glow on funnel status text */
@keyframes sufficiencyPulse {
0%, 100% { fill-opacity: 1; }
50% { fill-opacity: 0.6; }
}
.sufficiency-glow { animation: sufficiencyPulse 2s ease-in-out infinite; }
/* ── Mobile responsive ──────────────────────────────── */
@media (max-width: 768px) {
.funds-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; }