feat: inline config beside node, simplified funnel outflow slider

- Inline config panel now appears beside (right of) the node instead of
  beneath it
- Funnel config simplified to single $/mo outflow slider (0-3000) that
  auto-derives all thresholds: min=1mo, sufficient=4mo, overflow=6mo
- Updated deriveThresholds to 4mo sufficient (was 3mo)
- Demo presets updated to match new 4mo formula

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-05 16:32:38 -08:00
parent ecebf84bcc
commit 0318f0a7e1
4 changed files with 61 additions and 29 deletions

View File

@ -698,6 +698,19 @@
min-width: 40px; text-align: right; min-width: 40px; text-align: right;
} }
/* Derived threshold info */
.icp-derived-info {
margin-top: 6px; padding: 6px 0 0;
border-top: 1px solid var(--rs-border-subtle);
}
.icp-derived-row {
display: flex; align-items: center; gap: 6px;
font-size: 10px; color: var(--rs-text-secondary); margin-bottom: 3px;
}
.icp-derived-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
/* Analytics bars */ /* Analytics bars */
.icp-analytics-row { .icp-analytics-row {
margin-bottom: 8px; margin-bottom: 8px;

View File

@ -2444,11 +2444,11 @@ class FolkFlowsApp extends HTMLElement {
this.renderFunnelThresholdMarkers(overlay, node, s); this.renderFunnelThresholdMarkers(overlay, node, s);
} }
// Panel positioned below the node // Panel positioned beside the node (right side)
const panelW = Math.max(280, s.w); const panelW = 280;
const panelH = 260; const panelH = 260;
const panelX = (s.w - panelW) / 2; const panelX = s.w + 12;
const panelY = s.h + 8; const panelY = 0;
overlay.innerHTML += ` overlay.innerHTML += `
<foreignObject x="${panelX}" y="${panelY}" width="${panelW}" height="${panelH}"> <foreignObject x="${panelX}" y="${panelY}" width="${panelW}" height="${panelH}">
@ -2504,30 +2504,20 @@ class FolkFlowsApp extends HTMLElement {
private renderFunnelConfigTab(node: FlowNode): string { private renderFunnelConfigTab(node: FlowNode): string {
const d = node.data as FunnelNodeData; const d = node.data as FunnelNodeData;
const cap = d.maxCapacity || 1; const outflow = d.desiredOutflow || 0;
const sufVal = d.sufficientThreshold ?? d.maxThreshold;
return ` return `
<div class="icp-field"><label class="icp-label">Label</label> <div class="icp-field"><label class="icp-label">Label</label>
<input class="icp-input" data-icp-field="label" value="${this.esc(d.label)}"/></div> <input class="icp-input" data-icp-field="label" value="${this.esc(d.label)}"/></div>
<div class="icp-range-group"> <div class="icp-range-group">
<span class="icp-range-label">Min</span> <span class="icp-range-label">$/mo</span>
<input class="icp-range" type="range" min="0" max="${cap}" value="${d.minThreshold}" data-icp-range="minThreshold"/> <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(d.minThreshold)}</span> <span class="icp-range-value">${this.formatDollar(outflow)}</span>
</div> </div>
<div class="icp-range-group"> <div class="icp-derived-info">
<span class="icp-range-label">Suf</span> <div class="icp-derived-row"><span class="icp-derived-dot" style="background:var(--rflows-status-critical)"></span>Min (1mo): ${this.formatDollar(d.minThreshold)}</div>
<input class="icp-range" type="range" min="0" max="${cap}" value="${sufVal}" data-icp-range="sufficientThreshold"/> <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>
<span class="icp-range-value">${this.formatDollar(sufVal)}</span> <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> </div>`;
<div class="icp-range-group">
<span class="icp-range-label">Max</span>
<input class="icp-range" type="range" min="0" max="${cap}" value="${d.maxThreshold}" data-icp-range="maxThreshold"/>
<span class="icp-range-value">${this.formatDollar(d.maxThreshold)}</span>
</div>
<div class="icp-field"><label class="icp-label">Max Capacity</label>
<input class="icp-input" data-icp-field="maxCapacity" type="number" value="${d.maxCapacity}"/></div>
<div class="icp-field"><label class="icp-label">Inflow Rate ($/tick)</label>
<input class="icp-input" data-icp-field="inflowRate" type="number" value="${d.inflowRate}"/></div>`;
} }
private renderOutcomeConfigTab(node: FlowNode): string { private renderOutcomeConfigTab(node: FlowNode): string {
@ -2777,6 +2767,35 @@ class FolkFlowsApp extends HTMLElement {
this.redrawThresholdMarkers(node); 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) { private attachThresholdDragListeners(overlay: Element, node: FlowNode) {
@ -2995,7 +3014,7 @@ class FolkFlowsApp extends HTMLElement {
<div class="editor-section-title">Thresholds ${derived ? "(auto-derived from outflow)" : ""}</div> <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> <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> <input class="editor-input" data-field="minThreshold" type="number" value="${d.minThreshold}"/></div>
<div class="editor-field"><label class="editor-label">Sufficient (3mo)${derived ? `${this.formatDollar(derived.sufficientThreshold)}` : ""}</label> <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> <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> <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> <input class="editor-input" data-field="maxThreshold" type="number" value="${d.maxThreshold}"/></div>

View File

@ -19,7 +19,7 @@ export const demoNodes: FlowNode[] = [
id: "treasury", type: "funnel", position: { x: 630, y: 0 }, id: "treasury", type: "funnel", position: { x: 630, y: 0 },
data: { data: {
label: "Treasury", currentValue: 85000, desiredOutflow: 10000, label: "Treasury", currentValue: 85000, desiredOutflow: 10000,
minThreshold: 10000, sufficientThreshold: 30000, maxThreshold: 60000, minThreshold: 10000, sufficientThreshold: 40000, maxThreshold: 60000,
maxCapacity: 90000, inflowRate: 1000, dynamicOverflow: true, maxCapacity: 90000, inflowRate: 1000, dynamicOverflow: true,
overflowAllocations: [ overflowAllocations: [
{ targetId: "public-goods", percentage: 40, color: OVERFLOW_COLORS[0] }, { targetId: "public-goods", percentage: 40, color: OVERFLOW_COLORS[0] },
@ -35,7 +35,7 @@ export const demoNodes: FlowNode[] = [
id: "public-goods", type: "funnel", position: { x: 170, y: 450 }, id: "public-goods", type: "funnel", position: { x: 170, y: 450 },
data: { data: {
label: "Public Goods", currentValue: 45000, desiredOutflow: 7000, label: "Public Goods", currentValue: 45000, desiredOutflow: 7000,
minThreshold: 7000, sufficientThreshold: 21000, maxThreshold: 42000, minThreshold: 7000, sufficientThreshold: 28000, maxThreshold: 42000,
maxCapacity: 63000, inflowRate: 400, maxCapacity: 63000, inflowRate: 400,
overflowAllocations: [], overflowAllocations: [],
spendingAllocations: [ spendingAllocations: [
@ -49,7 +49,7 @@ export const demoNodes: FlowNode[] = [
id: "research", type: "funnel", position: { x: 975, y: 450 }, id: "research", type: "funnel", position: { x: 975, y: 450 },
data: { data: {
label: "Research", currentValue: 28000, desiredOutflow: 5000, label: "Research", currentValue: 28000, desiredOutflow: 5000,
minThreshold: 5000, sufficientThreshold: 15000, maxThreshold: 30000, minThreshold: 5000, sufficientThreshold: 20000, maxThreshold: 30000,
maxCapacity: 45000, inflowRate: 350, maxCapacity: 45000, inflowRate: 350,
overflowAllocations: [], overflowAllocations: [],
spendingAllocations: [ spendingAllocations: [
@ -62,7 +62,7 @@ export const demoNodes: FlowNode[] = [
id: "emergency", type: "funnel", position: { x: 1320, y: 450 }, id: "emergency", type: "funnel", position: { x: 1320, y: 450 },
data: { data: {
label: "Emergency", currentValue: 12000, desiredOutflow: 8000, label: "Emergency", currentValue: 12000, desiredOutflow: 8000,
minThreshold: 8000, sufficientThreshold: 24000, maxThreshold: 48000, minThreshold: 8000, sufficientThreshold: 32000, maxThreshold: 48000,
maxCapacity: 72000, inflowRate: 250, maxCapacity: 72000, inflowRate: 250,
overflowAllocations: [], overflowAllocations: [],
spendingAllocations: [ spendingAllocations: [

View File

@ -58,7 +58,7 @@ export interface FunnelNodeData {
export function deriveThresholds(desiredOutflow: number) { export function deriveThresholds(desiredOutflow: number) {
return { return {
minThreshold: desiredOutflow * 1, // 1 month runway minThreshold: desiredOutflow * 1, // 1 month runway
sufficientThreshold: desiredOutflow * 3, // 3 months runway sufficientThreshold: desiredOutflow * 4, // 4 months runway (1 min + 3 buffer)
maxThreshold: desiredOutflow * 6, // overflow point maxThreshold: desiredOutflow * 6, // overflow point
maxCapacity: desiredOutflow * 9, // visual max maxCapacity: desiredOutflow * 9, // visual max
}; };