Merge branch 'dev'
CI/CD / deploy (push) Failing after 2m22s
Details
CI/CD / deploy (push) Failing after 2m22s
Details
This commit is contained in:
commit
d68f01e2b0
|
|
@ -1,36 +0,0 @@
|
|||
---
|
||||
id: 120
|
||||
title: Universal Profiles × EncryptID integration
|
||||
status: In Progress
|
||||
priority: high
|
||||
created: 2026-03-16
|
||||
---
|
||||
|
||||
## Description
|
||||
Give every EncryptID user a LUKSO Universal Profile (LSP0 + LSP6) on Base, controlled by their passkey-derived secp256k1 key.
|
||||
|
||||
## Phase 1: Core (DONE)
|
||||
- [x] EVM key derivation (`encryptid-sdk/src/client/evm-key.ts`) — HKDF secp256k1 from PRF
|
||||
- [x] UP deployment service (`encryptid-up-service/`) — Hono API with CREATE2, LSP6 permissions, LSP25 relay
|
||||
- [x] SDK types — `eid.up` in JWT claims, `LSP6Permission` enum, UP request/response types
|
||||
- [x] Session UP helpers — `getUPAddress()`, `hasUniversalProfile()`, `setUniversalProfile()`
|
||||
- [x] Recovery hooks — `onUPRecovery()` for on-chain controller rotation
|
||||
- [x] Schema migration — UP columns on users table
|
||||
- [x] Server endpoints — `GET/POST /api/profile/:id/up`, UP info in JWT claims
|
||||
|
||||
## Phase 2: UP-Aware Sessions
|
||||
- [ ] Map EncryptID AuthLevel → LSP6 BitArray permissions on-chain
|
||||
- [ ] Guardian → LSP6 controller mapping with ADDPERMISSIONS
|
||||
|
||||
## Phase 3: Payment-Infra Migration
|
||||
- [ ] WalletAdapter abstraction (UP + Openfort)
|
||||
- [ ] New users → UP by default
|
||||
|
||||
## Phase 4: NLA Oracle Integration
|
||||
- [ ] `getEncryptIDWallet()` for CLI
|
||||
- [ ] Escrow parties identified by UP address
|
||||
|
||||
## Notes
|
||||
- encryptid-up-service repo: https://gitea.jeffemmett.com/jeffemmett/encryptid-up-service
|
||||
- Chain: Base Sepolia (84532) for dev, Base mainnet for prod
|
||||
- LSP contracts are EVM-compatible, deployed on Base
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
---
|
||||
id: TASK-120
|
||||
title: Universal Profiles × EncryptID integration
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: ''
|
||||
updated_date: '2026-04-10 23:25'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Give every EncryptID user a LUKSO Universal Profile (LSP0 + LSP6) on Base, controlled by their passkey-derived secp256k1 key.
|
||||
|
||||
## Phase 1: Core (DONE)
|
||||
- [x] EVM key derivation (`encryptid-sdk/src/client/evm-key.ts`) — HKDF secp256k1 from PRF
|
||||
- [x] UP deployment service (`encryptid-up-service/`) — Hono API with CREATE2, LSP6 permissions, LSP25 relay
|
||||
- [x] SDK types — `eid.up` in JWT claims, `LSP6Permission` enum, UP request/response types
|
||||
- [x] Session UP helpers — `getUPAddress()`, `hasUniversalProfile()`, `setUniversalProfile()`
|
||||
- [x] Recovery hooks — `onUPRecovery()` for on-chain controller rotation
|
||||
- [x] Schema migration — UP columns on users table
|
||||
- [x] Server endpoints — `GET/POST /api/profile/:id/up`, UP info in JWT claims
|
||||
|
||||
## Phase 2: UP-Aware Sessions
|
||||
- [x] Map EncryptID AuthLevel → LSP6 BitArray permissions (scaffolding — `lsp6.ts` mapper)
|
||||
- [ ] Guardian → LSP6 controller mapping with ADDPERMISSIONS
|
||||
- [ ] On-chain permission write (requires LSP factory deployment)
|
||||
|
||||
## Phase 3: Payment-Infra Migration
|
||||
- [x] WalletAdapter abstraction (UP + Safe + EOA) — `wallet-adapter.ts`
|
||||
- [ ] New users → UP by default
|
||||
|
||||
## Phase 4: NLA Oracle Integration
|
||||
- [x] `getEncryptIDWallet()` for CLI — `wallet-helper.ts`
|
||||
- [ ] Escrow parties identified by UP address
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Notes
|
||||
- encryptid-up-service repo: https://gitea.jeffemmett.com/jeffemmett/encryptid-up-service
|
||||
- Chain: Base Sepolia (84532) for dev, Base mainnet for prod
|
||||
- LSP contracts are EVM-compatible, deployed on Base
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
**2026-04-10 Architecture Decision — Chain-Parameterized WalletAdapter:**
|
||||
Phase 3 WalletAdapter MUST be built with `chainId` parameter from day one, not Base-hardcoded. This enables adding Linea (59144/59141) or any EVM L2 as: add chain config → deploy LSP factory → done. Add Linea to CHAIN_MAP alongside the adapter work. CREATE2 determinism should work on Linea's zkEVM but LSP factory contracts need deployment there. Current state: wallet module reads 13+ chains but UP write operations are Base-only.
|
||||
|
||||
## Phases 2-4 Implementation (2026-04-10)
|
||||
- **Linea chain support**: Added Linea mainnet (59144) + Linea Sepolia (59141) to all 6 chain maps in rwallet/mod.ts, price-feed, defi-positions, wallet-viewer, and encryptid server CHAIN_PREFIXES. Popular tokens: USDC, WETH, USDT on Linea.
|
||||
- **WalletAdapter** (`src/encryptid/wallet-adapter.ts`): Chain-parameterized abstraction over Safe/EOA/UP with `fromSafe()`, `fromEOA()`, `fromUP()` factories, immutable `withUniversalProfile()`, `getInfo()`, `toJSON()`.
|
||||
- **LSP6 Permission Mapper** (`encryptid-sdk/src/types/lsp6.ts`): 23-bit `LSP6Permission` enum, `buildBitmap()`, `hasPermission()`, `mergePermissions()`, `AUTH_LEVEL_PERMISSIONS` mapping BASIC→CRITICAL, `GUARDIAN_PERMISSIONS`, `getPermissionsForAuthLevel()`. Removed duplicate inline enum from types/index.ts.
|
||||
- **getEncryptIDWallet()** (`encryptid-sdk/src/client/wallet-helper.ts`): SDK helper returns read-only `EncryptIDWalletInfo` snapshot (EOA, DID, username, UP, auth level, compressed pubkey) for CLI/oracle. Never exposes private keys.
|
||||
- **SDK exports**: All new types/functions re-exported from types/index.ts, client/index.ts, src/index.ts.
|
||||
- Deployed to production. rspace.online returns 200.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
id: TASK-142
|
||||
title: miC — Voice Conversation Mode for MI Agent
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-10 22:40'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add a "miC" toggle button to the MI agent that enables a full voice conversation loop: speak → transcribe → auto-submit to MI → speak response aloud → listen again.
|
||||
|
||||
## Implementation
|
||||
- `lib/mi-voice-bridge.ts`: MiVoiceBridge class — Edge TTS via `claude-voice.jeffemmett.com` WebSocket + Web Speech Synthesis fallback
|
||||
- `shared/components/rstack-mi.ts`: Voice mode state machine (IDLE → LISTENING → THINKING → SPEAKING → LISTENING), miC buttons in bar + panel header, voice status strip with waveform animation, auto-submit on 1.5s silence, TTS truncation (strips markdown/code, limits to ~4 sentences), echo prevention, interruption support
|
||||
|
||||
## Key Decisions
|
||||
- Separate SpeechDictation instance from bar dictation (browser only allows one SpeechRecognition)
|
||||
- No server changes — uses existing #ask() flow and parseMiActions()
|
||||
- Edge TTS primary, browser speechSynthesis fallback
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
id: TASK-143
|
||||
title: Customizable Dashboard with Persistent Home Icon
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-11 03:18'
|
||||
updated_date: '2026-04-11 03:18'
|
||||
labels:
|
||||
- dashboard
|
||||
- ux
|
||||
- tab-bar
|
||||
dependencies: []
|
||||
references:
|
||||
- shared/components/rstack-tab-bar.ts
|
||||
- shared/components/rstack-user-dashboard.ts
|
||||
- server/dashboard-routes.ts
|
||||
- server/shell.ts
|
||||
- shared/tab-cache.ts
|
||||
- server/index.ts
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add always-visible home button in tab bar and customizable widget dashboard system. Persistent home icon toggles dashboard overlay even with tabs open. 8 widget cards (tasks, calendar, activity, members, tools, quick actions, wallet, flows) with toggle/reorder customization persisted to localStorage. Dashboard summary API aggregates data from multiple modules in a single endpoint.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Home icon always visible in tab bar, even with tabs open
|
||||
- [x] #2 Click home icon toggles dashboard overlay on/off
|
||||
- [x] #3 Dashboard shows when all tabs closed (existing behavior preserved)
|
||||
- [x] #4 8 widget cards: tasks, calendar, activity, members, tools, quick actions, wallet, flows
|
||||
- [x] #5 Customize mode with toggle checkboxes and reorder arrows
|
||||
- [x] #6 Widget config persisted to localStorage per space
|
||||
- [x] #7 Dashboard summary API at /api/dashboard-summary/:space
|
||||
- [x] #8 Auth-gated widgets (activity, wallet) show sign-in prompts when logged out
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Implemented persistent home icon in tab bar and full widget-based dashboard system.\n\nFiles modified:\n- `rstack-tab-bar.ts`: Permanent home button with home-click event and home-active observed attribute\n- `rstack-user-dashboard.ts`: Full refactor with widget registry, config persistence, customize mode, 8 widget cards with per-widget data loading\n- `server/shell.ts`: home-click listener for dashboard overlay toggle, home-active tracking on layer-switch and dashboard-navigate\n- `shared/tab-cache.ts`: Clear home-active on popstate back-to-tab\n- `server/dashboard-routes.ts` (NEW): GET /api/dashboard-summary/:space aggregation endpoint\n- `server/index.ts`: Mount dashboard routes\n\nCommit: e632858\nDeployed to rspace.online and verified API returns tasks/calendar/flows data.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -10,22 +10,22 @@
|
|||
|
||||
/* Edge colors */
|
||||
--rflows-edge-inflow: #10b981;
|
||||
--rflows-edge-spending: #34d399;
|
||||
--rflows-edge-overflow: #6ee7b7;
|
||||
--rflows-edge-spending: #3b82f6;
|
||||
--rflows-edge-overflow: #f59e0b;
|
||||
|
||||
/* Funnel zones */
|
||||
--rflows-zone-drain: #ef4444;
|
||||
--rflows-zone-drain-opacity: 0.08;
|
||||
--rflows-zone-healthy: #0ea5e9;
|
||||
--rflows-zone-healthy-opacity: 0.06;
|
||||
--rflows-zone-healthy-opacity: 0.10;
|
||||
--rflows-zone-overflow: #f59e0b;
|
||||
--rflows-zone-overflow-opacity: 0.06;
|
||||
--rflows-fill-opacity: 0.25;
|
||||
--rflows-zone-overflow-opacity: 0.12;
|
||||
--rflows-fill-opacity: 0.45;
|
||||
|
||||
/* Funnel labels */
|
||||
--rflows-label-inflow: #10b981;
|
||||
--rflows-label-spending: #34d399;
|
||||
--rflows-label-overflow: #6ee7b7;
|
||||
--rflows-label-spending: #3b82f6;
|
||||
--rflows-label-overflow: #f59e0b;
|
||||
|
||||
/* Status colors */
|
||||
--rflows-status-critical: #ef4444;
|
||||
|
|
@ -885,6 +885,20 @@
|
|||
/* ── Basin ripple wave ─────────────────────────────── */
|
||||
.basin-ripple { opacity: 0.7; }
|
||||
|
||||
/* ── Approaching overflow glow ───────────────────── */
|
||||
.approaching-glow { animation: approachingPulse 1.5s ease-in-out infinite; }
|
||||
@keyframes approachingPulse {
|
||||
0%, 100% { opacity: 0.15; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
/* ── Approaching status badge ────────────────────── */
|
||||
.flows-status--approaching { color: #f59e0b; }
|
||||
.icp-suf-badge--approaching {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* ── Simulation speed slider ──────────────────────── */
|
||||
.flows-sim-speed {
|
||||
position: absolute; bottom: 50px; right: 10px; z-index: 10;
|
||||
|
|
@ -955,19 +969,19 @@
|
|||
|
||||
/* Edge colors */
|
||||
--rflows-edge-inflow: #059669;
|
||||
--rflows-edge-spending: #047857;
|
||||
--rflows-edge-overflow: #059669;
|
||||
--rflows-edge-spending: #2563eb;
|
||||
--rflows-edge-overflow: #d97706;
|
||||
|
||||
/* Funnel zones */
|
||||
--rflows-zone-drain-opacity: 0.15;
|
||||
--rflows-zone-healthy-opacity: 0.12;
|
||||
--rflows-zone-overflow-opacity: 0.12;
|
||||
--rflows-fill-opacity: 0.35;
|
||||
--rflows-zone-healthy-opacity: 0.14;
|
||||
--rflows-zone-overflow-opacity: 0.14;
|
||||
--rflows-fill-opacity: 0.45;
|
||||
|
||||
/* Funnel labels */
|
||||
--rflows-label-inflow: #047857;
|
||||
--rflows-label-spending: #047857;
|
||||
--rflows-label-overflow: #059669;
|
||||
--rflows-label-spending: #2563eb;
|
||||
--rflows-label-overflow: #d97706;
|
||||
|
||||
/* Status colors (darken for light bg) */
|
||||
--rflows-status-overflow: #059669;
|
||||
|
|
@ -1007,17 +1021,17 @@
|
|||
--rflows-source-rate: #047857;
|
||||
|
||||
--rflows-edge-inflow: #059669;
|
||||
--rflows-edge-spending: #047857;
|
||||
--rflows-edge-overflow: #059669;
|
||||
--rflows-edge-spending: #2563eb;
|
||||
--rflows-edge-overflow: #d97706;
|
||||
|
||||
--rflows-zone-drain-opacity: 0.15;
|
||||
--rflows-zone-healthy-opacity: 0.12;
|
||||
--rflows-zone-overflow-opacity: 0.12;
|
||||
--rflows-fill-opacity: 0.35;
|
||||
--rflows-zone-healthy-opacity: 0.14;
|
||||
--rflows-zone-overflow-opacity: 0.14;
|
||||
--rflows-fill-opacity: 0.45;
|
||||
|
||||
--rflows-label-inflow: #047857;
|
||||
--rflows-label-spending: #047857;
|
||||
--rflows-label-overflow: #059669;
|
||||
--rflows-label-spending: #2563eb;
|
||||
--rflows-label-overflow: #d97706;
|
||||
|
||||
--rflows-status-overflow: #059669;
|
||||
--rflows-status-thriving: #059669;
|
||||
|
|
|
|||
|
|
@ -108,11 +108,14 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
};
|
||||
});
|
||||
|
||||
// Position funnels
|
||||
// Position funnels — dynamic vessel width based on widest layer
|
||||
const funnelStartY = sourceStartY + SOURCE_H + LAYER_GAP;
|
||||
const maxLayerSize = Math.max(1, ...layers.map((l) => l.length));
|
||||
const dynamicVesselW = Math.min(200, Math.max(120, 800 / maxLayerSize));
|
||||
const dynamicVesselBW = dynamicVesselW * (VESSEL_BW / VESSEL_W);
|
||||
const funnelLayouts: FunnelLayout[] = [];
|
||||
layers.forEach((layer, layerIdx) => {
|
||||
const totalW = layer.length * VESSEL_W + (layer.length - 1) * H_GAP;
|
||||
const totalW = layer.length * dynamicVesselW + (layer.length - 1) * H_GAP;
|
||||
const layerY = funnelStartY + layerIdx * (VESSEL_H + LAYER_GAP);
|
||||
layer.forEach((n, i) => {
|
||||
const data = n.data as FunnelNodeData;
|
||||
|
|
@ -120,8 +123,8 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
const overflowLevel = data.overflowThreshold / (data.capacity || 1);
|
||||
funnelLayouts.push({
|
||||
id: n.id, label: data.label, data,
|
||||
x: -totalW / 2 + i * (VESSEL_W + H_GAP), y: layerY,
|
||||
w: VESSEL_W, h: VESSEL_H, bw: VESSEL_BW,
|
||||
x: -totalW / 2 + i * (dynamicVesselW + H_GAP), y: layerY,
|
||||
w: dynamicVesselW, h: VESSEL_H, bw: dynamicVesselBW,
|
||||
fillLevel, overflowLevel,
|
||||
sufficiency: computeSufficiencyState(data),
|
||||
});
|
||||
|
|
@ -240,34 +243,51 @@ function renderBand(b: BandLayout): string {
|
|||
const hw = b.width / 2;
|
||||
const dx = b.x2 - b.x1;
|
||||
const dy = b.y2 - b.y1;
|
||||
if (dy <= 0) return "";
|
||||
|
||||
// Cubic bezier ribbon — L-shaped path for horizontal displacement
|
||||
const hDisp = Math.abs(dx);
|
||||
const bendY = hDisp > 20 ? b.y1 + Math.min(dy * 0.3, 40) : b.y1 + dy * 0.4;
|
||||
let path: string;
|
||||
let center: string;
|
||||
let midX: number;
|
||||
let midY: number;
|
||||
|
||||
if (dy > 20) {
|
||||
// Downward flows: existing cubic bezier ribbon
|
||||
const cp1y = b.y1 + dy * 0.15;
|
||||
const cp2y = b.y2 - dy * 0.15;
|
||||
|
||||
// Left edge and right edge of ribbon
|
||||
const path = [
|
||||
path = [
|
||||
`M ${b.x1 - hw} ${b.y1}`,
|
||||
`C ${b.x1 - hw} ${cp1y}, ${b.x2 - hw} ${cp2y}, ${b.x2 - hw} ${b.y2}`,
|
||||
`L ${b.x2 + hw} ${b.y2}`,
|
||||
`C ${b.x2 + hw} ${cp2y}, ${b.x1 + hw} ${cp1y}, ${b.x1 + hw} ${b.y1}`,
|
||||
`Z`,
|
||||
].join(" ");
|
||||
center = `M ${b.x1} ${b.y1} C ${b.x1} ${cp1y}, ${b.x2} ${cp2y}, ${b.x2} ${b.y2}`;
|
||||
midX = (b.x1 + b.x2) / 2;
|
||||
midY = (b.y1 + b.y2) / 2;
|
||||
} else {
|
||||
// Upward/same-level flows: loopback arc routing outward then to target
|
||||
const loopRadius = Math.max(80, Math.abs(dy) * 0.4 + Math.abs(dx) * 0.3);
|
||||
// Route outward based on dx direction (or right if same x)
|
||||
const outDir = dx >= 0 ? 1 : -1;
|
||||
const arcX = b.x1 + outDir * loopRadius;
|
||||
const arcY = Math.min(b.y1, b.y2) - loopRadius * 0.6;
|
||||
|
||||
// Center-line for direction animation
|
||||
const center = `M ${b.x1} ${b.y1} C ${b.x1} ${cp1y}, ${b.x2} ${cp2y}, ${b.x2} ${b.y2}`;
|
||||
|
||||
// Label at midpoint
|
||||
const midX = (b.x1 + b.x2) / 2;
|
||||
const midY = (b.y1 + b.y2) / 2;
|
||||
// Ribbon left/right edges via cubic bezier through the arc point
|
||||
path = [
|
||||
`M ${b.x1 - hw} ${b.y1}`,
|
||||
`C ${arcX - hw} ${arcY}, ${arcX - hw} ${arcY}, ${b.x2 - hw} ${b.y2}`,
|
||||
`L ${b.x2 + hw} ${b.y2}`,
|
||||
`C ${arcX + hw} ${arcY}, ${arcX + hw} ${arcY}, ${b.x1 + hw} ${b.y1}`,
|
||||
`Z`,
|
||||
].join(" ");
|
||||
center = `M ${b.x1} ${b.y1} C ${arcX} ${arcY}, ${arcX} ${arcY}, ${b.x2} ${b.y2}`;
|
||||
midX = arcX;
|
||||
midY = arcY + loopRadius * 0.3;
|
||||
}
|
||||
|
||||
return `
|
||||
<path d="${path}" fill="${b.color}" opacity="0.25"/>
|
||||
<path d="${path}" fill="none" stroke="${b.color}" stroke-width="0.5" opacity="0.5"/>
|
||||
<path d="${center}" fill="none" stroke="${b.color}" stroke-width="1.5" opacity="0.6" stroke-dasharray="6 8" class="flow-dash"/>
|
||||
<path d="${path}" fill="${b.color}" opacity="0.45"/>
|
||||
<path d="${path}" fill="none" stroke="${b.color}" stroke-width="0.5" opacity="0.75"/>
|
||||
<path d="${center}" fill="none" stroke="${b.color}" stroke-width="1.5" opacity="0.75" stroke-dasharray="6 8" class="flow-dash"/>
|
||||
<text x="${midX}" y="${midY - 4}" text-anchor="middle" fill="${b.color}" font-size="9" font-weight="600" opacity="0.9">${b.label}</text>`;
|
||||
}
|
||||
|
||||
|
|
@ -306,19 +326,23 @@ function renderFunnel(f: FunnelLayout): string {
|
|||
const ovEdges = vesselEdgesAtY(f.x, f.w, f.bw, ovFrac);
|
||||
|
||||
const isOverflowing = f.sufficiency === "overflowing";
|
||||
const fillColor = isOverflowing ? COLORS.overflow : COLORS.inflow;
|
||||
const isApproaching = f.sufficiency === "approaching";
|
||||
const fillColor = isOverflowing ? COLORS.overflow : isApproaching ? "#f59e0b" : COLORS.inflow;
|
||||
|
||||
// Value and drain labels
|
||||
const val = f.data.currentValue;
|
||||
const drain = f.data.drainRate;
|
||||
|
||||
const approachingGlow = isApproaching ? `<path d="${vesselPath}" fill="none" stroke="#f59e0b" stroke-width="3" opacity="0.3" class="approaching-glow"/>` : "";
|
||||
|
||||
return `
|
||||
<defs><clipPath id="${clipId}"><path d="${vesselPath}"/></clipPath></defs>
|
||||
${approachingGlow}
|
||||
<path d="${vesselPath}" fill="${COLORS.surface}" stroke="${COLORS.surfaceRaised}" stroke-width="1.5" opacity="0.9"/>
|
||||
${fillPath ? `<g clip-path="url(#${clipId})"><path d="${fillPath}" fill="${fillColor}" opacity="0.45"/></g>` : ""}
|
||||
<line x1="${ovEdges.left + 4}" y1="${ovY}" x2="${ovEdges.right - 4}" y2="${ovY}" stroke="${COLORS.overflow}" stroke-width="1.5" stroke-dasharray="5 4" opacity="0.6"/>
|
||||
<text x="${cx}" y="${f.y - 12}" text-anchor="middle" fill="${COLORS.text}" font-size="13" font-weight="600">${esc(f.label)}</text>
|
||||
<text x="${cx}" y="${f.y - 1}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="10">${fmtDollars(val)} | drain ${fmtDollars(drain)}/mo</text>
|
||||
<text x="${cx}" y="${f.y - 1}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="10">${fmtDollars(val)}</text>
|
||||
<rect x="${f.x + 10}" y="${f.y + f.h + 6}" width="${f.w - 20}" height="3" rx="1.5" fill="${COLORS.surfaceRaised}"/>
|
||||
<rect x="${f.x + 10}" y="${f.y + f.h + 6}" width="${(f.w - 20) * Math.min(1, f.fillLevel)}" height="3" rx="1.5" fill="${fillColor}"/>`;
|
||||
}
|
||||
|
|
@ -498,6 +522,8 @@ class FolkFlowRiver extends HTMLElement {
|
|||
.amount-popover button:hover { opacity: 0.85; }
|
||||
.flow-dash { animation: dashFlow 1s linear infinite; }
|
||||
@keyframes dashFlow { to { stroke-dashoffset: -14; } }
|
||||
.approaching-glow { animation: approachingPulse 1.5s ease-in-out infinite; }
|
||||
@keyframes approachingPulse { 0%,100% { opacity:0.15 } 50% { opacity:0.45 } }
|
||||
</style>
|
||||
<div class="container">
|
||||
<svg viewBox="0 0 ${layout.width} ${layout.height}" width="${layout.width}" height="${layout.height}">
|
||||
|
|
|
|||
|
|
@ -873,9 +873,10 @@ class FolkFlowsApp extends HTMLElement {
|
|||
const suffPct = Math.min(100, (data.currentValue / (data.overflowThreshold || 1)) * 100);
|
||||
|
||||
const statusClass = sufficiency === "overflowing" ? "flows-status--abundant"
|
||||
: sufficiency === "approaching" ? "flows-status--approaching"
|
||||
: "flows-status--seeking";
|
||||
|
||||
const statusLabel = sufficiency === "overflowing" ? "Overflowing" : "Seeking";
|
||||
const statusLabel = sufficiency === "overflowing" ? "Overflowing" : sufficiency === "approaching" ? "Approaching" : "Seeking";
|
||||
|
||||
return `
|
||||
<div class="flows-card">
|
||||
|
|
@ -1068,6 +1069,18 @@ class FolkFlowsApp extends HTMLElement {
|
|||
<stop offset="0%" stop-color="#fca5a5" stop-opacity="0.6"/>
|
||||
<stop offset="100%" stop-color="#ef4444" stop-opacity="0.35"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="funnel-fill-green" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#10b981" stop-opacity="0.6"/>
|
||||
<stop offset="100%" stop-color="#10b981" stop-opacity="0.3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="funnel-fill-amber" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#f59e0b" stop-opacity="0.6"/>
|
||||
<stop offset="100%" stop-color="#f59e0b" stop-opacity="0.3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="funnel-fill-blue" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.6"/>
|
||||
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0.3"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="canvas-transform">
|
||||
<g id="edge-layer"></g>
|
||||
|
|
@ -1993,9 +2006,10 @@ class FolkFlowsApp extends HTMLElement {
|
|||
const fillPct = Math.min(1, d.currentValue / (d.capacity || 1));
|
||||
|
||||
const isOverflow = d.currentValue > d.overflowThreshold;
|
||||
const borderColorVar = isOverflow ? "var(--rflows-status-overflow)" : "var(--rflows-status-sustained)";
|
||||
const isApproaching = !isOverflow && d.currentValue >= d.overflowThreshold * 0.8;
|
||||
const borderColorVar = isOverflow ? "var(--rflows-status-overflow)" : isApproaching ? "#f59e0b" : "var(--rflows-status-sustained)";
|
||||
const fillColor = borderColorVar;
|
||||
const statusLabel = isOverflow ? "Overflow" : "Seeking";
|
||||
const statusLabel = isOverflow ? "Overflow" : isApproaching ? "Approaching" : "Seeking";
|
||||
|
||||
// Vessel shape parameters
|
||||
const r = 10;
|
||||
|
|
@ -2137,7 +2151,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
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))" : "";
|
||||
const glowStyle = isOverflow ? "filter: drop-shadow(0 0 8px rgba(16,185,129,0.5))" : isApproaching ? "filter: drop-shadow(0 0 6px rgba(245,158,11,0.4))" : "";
|
||||
|
||||
// Rate labels
|
||||
const inflowLabel = `${this.formatDollar(d.inflowRate)}/mo`;
|
||||
|
|
@ -2145,8 +2159,8 @@ class FolkFlowsApp extends HTMLElement {
|
|||
const overflowLabel = isOverflow ? this.formatDollar(excess) : "";
|
||||
|
||||
// Status badge colors
|
||||
const statusBadgeBg = isOverflow ? "rgba(16,185,129,0.15)" : "rgba(59,130,246,0.15)";
|
||||
const statusBadgeColor = isOverflow ? "#10b981" : "#3b82f6";
|
||||
const statusBadgeBg = isOverflow ? "rgba(16,185,129,0.15)" : isApproaching ? "rgba(245,158,11,0.15)" : "rgba(59,130,246,0.15)";
|
||||
const statusBadgeColor = isOverflow ? "#10b981" : isApproaching ? "#f59e0b" : "#3b82f6";
|
||||
|
||||
// Drain spout inset for valve handle positioning
|
||||
const drainInset = taperAtBottom;
|
||||
|
|
@ -2156,11 +2170,12 @@ class FolkFlowsApp extends HTMLElement {
|
|||
<clipPath id="${clipId}"><path d="${vesselPath}"/></clipPath>
|
||||
</defs>
|
||||
${isOverflow ? `<path d="${vesselPath}" 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})"/>` : ""}
|
||||
${isApproaching ? `<path d="${vesselPath}" fill="none" stroke="#f59e0b" stroke-width="2.5" class="approaching-glow" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""}
|
||||
<path class="node-bg" d="${vesselPath}" 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}" width="${w + pipeW * 2}" height="${seekingH}" 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)"/>
|
||||
${fillPath ? `<path class="funnel-fill-path" data-node-id="${n.id}" d="${fillPath}" style="fill:${fillColor};opacity:var(--rflows-fill-opacity)"/>` : ""}
|
||||
${fillPath ? `<path class="funnel-fill-path" data-node-id="${n.id}" d="${fillPath}" style="fill:url(#${isOverflow ? "funnel-fill-green" : isApproaching ? "funnel-fill-amber" : "funnel-fill-blue"});opacity:var(--rflows-fill-opacity)"/>` : ""}
|
||||
${shimmerLine}
|
||||
${thresholdLines}
|
||||
</g>
|
||||
|
|
@ -2567,41 +2582,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
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;
|
||||
}
|
||||
const { d, midX, midY } = this._computeEdgeGeometry(x1, y1, x2, y2, strokeW, fromSide, waypoint);
|
||||
|
||||
// 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"/>`;
|
||||
|
|
@ -2645,8 +2626,8 @@ class FolkFlowsApp extends HTMLElement {
|
|||
if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges();
|
||||
}
|
||||
|
||||
/** Pure path computation — returns { d, midX, midY } */
|
||||
private computeEdgePath(
|
||||
/** Shared edge geometry computation — direction-aware for overflow routing */
|
||||
private _computeEdgeGeometry(
|
||||
x1: number, y1: number, x2: number, y2: number,
|
||||
strokeW: number, fromSide?: "left" | "right",
|
||||
waypoint?: { x: number; y: number },
|
||||
|
|
@ -2663,11 +2644,28 @@ class FolkFlowsApp extends HTMLElement {
|
|||
midX = waypoint.x;
|
||||
midY = waypoint.y;
|
||||
} else if (fromSide) {
|
||||
const dy = y2 - y1;
|
||||
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}`;
|
||||
|
||||
if (dy < -50) {
|
||||
// Upward: S-curve with outward horizontal burst then vertical arc above both nodes
|
||||
const peakY = Math.min(y1, y2) - burst * 0.6;
|
||||
d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${outwardX} ${peakY}, ${(x1 + x2) / 2} ${peakY} S ${x2} ${peakY}, ${x2} ${y2}`;
|
||||
midX = (x1 + x2) / 2;
|
||||
midY = peakY;
|
||||
} else if (Math.abs(dy) <= 80) {
|
||||
// Same-level: wide horizontal outward arc
|
||||
const arcY = Math.min(y1, y2) - burst * 0.5;
|
||||
d = `M ${x1} ${y1} C ${outwardX} ${arcY}, ${x2 + (outwardX - x1) * 0.3} ${arcY}, ${x2} ${y2}`;
|
||||
midX = (outwardX + x2) / 2;
|
||||
midY = arcY;
|
||||
} else {
|
||||
// Downward: existing bezier (works fine)
|
||||
d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + dy * 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;
|
||||
|
|
@ -2678,6 +2676,15 @@ class FolkFlowsApp extends HTMLElement {
|
|||
return { d, midX, midY };
|
||||
}
|
||||
|
||||
/** @deprecated Use _computeEdgeGeometry instead */
|
||||
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 } {
|
||||
return this._computeEdgeGeometry(x1, y1, x2, y2, strokeW, fromSide, waypoint);
|
||||
}
|
||||
|
||||
/** 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");
|
||||
|
|
@ -3429,7 +3436,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
const d = node.data as FunnelNodeData;
|
||||
const suf = computeSufficiencyState(d);
|
||||
const fillPct = Math.min(100, Math.round((d.currentValue / (d.overflowThreshold || 1)) * 100));
|
||||
const fillColor = suf === "seeking" ? "#3b82f6" : "#f59e0b";
|
||||
const fillColor = suf === "seeking" ? "#3b82f6" : suf === "approaching" ? "#f59e0b" : "#10b981";
|
||||
|
||||
const totalOut = (stats?.totalOutflow || 0) + (stats?.totalOverflow || 0);
|
||||
const outflowPct = totalOut > 0 ? Math.round(((stats?.totalOutflow || 0) / totalOut) * 100) : 50;
|
||||
|
|
@ -5029,6 +5036,14 @@ class FolkFlowsApp extends HTMLElement {
|
|||
|
||||
private startSimInterval() {
|
||||
if (this.simInterval) clearInterval(this.simInterval);
|
||||
// Pre-warmup: run 5 silent ticks to get flow into the system immediately
|
||||
for (let i = 0; i < 5; i++) {
|
||||
this.simTickCount++;
|
||||
this.nodes = computeInflowRates(this.nodes);
|
||||
this.nodes = simulateTick(this.nodes);
|
||||
this.accumulateNodeAnalytics();
|
||||
}
|
||||
this.updateCanvasLive();
|
||||
this.simInterval = setInterval(() => {
|
||||
this.simTickCount++;
|
||||
this.nodes = computeInflowRates(this.nodes);
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export const demoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "bcrg", type: "funnel", position: { x: 560, y: 0 },
|
||||
data: {
|
||||
label: "BCRG Treasury", currentValue: 0, drainRate: 6000,
|
||||
label: "BCRG Treasury", currentValue: 8000, drainRate: 6000,
|
||||
overflowThreshold: 35000, capacity: 50000, inflowRate: 20000,
|
||||
overflowAllocations: [
|
||||
{ targetId: "growth", percentage: 100, color: OVERFLOW_COLORS[0] },
|
||||
|
|
@ -63,7 +63,7 @@ export const demoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "programs", type: "funnel", position: { x: 100, y: 600 },
|
||||
data: {
|
||||
label: "Programs", currentValue: 0, drainRate: 2500,
|
||||
label: "Programs", currentValue: 2000, drainRate: 1800,
|
||||
overflowThreshold: 15000, capacity: 22000, inflowRate: 0,
|
||||
overflowAllocations: [
|
||||
{ targetId: "operations", percentage: 60, color: OVERFLOW_COLORS[1] },
|
||||
|
|
@ -78,7 +78,7 @@ export const demoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "operations", type: "funnel", position: { x: 560, y: 600 },
|
||||
data: {
|
||||
label: "Operations", currentValue: 0, drainRate: 2200,
|
||||
label: "Operations", currentValue: 1500, drainRate: 1600,
|
||||
overflowThreshold: 13200, capacity: 20000, inflowRate: 0,
|
||||
overflowAllocations: [
|
||||
{ targetId: "growth", percentage: 100, color: OVERFLOW_COLORS[0] },
|
||||
|
|
@ -92,7 +92,7 @@ export const demoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "growth", type: "funnel", position: { x: 1020, y: 600 },
|
||||
data: {
|
||||
label: "Growth", currentValue: 0, drainRate: 1500,
|
||||
label: "Growth", currentValue: 1000, drainRate: 1100,
|
||||
overflowThreshold: 9000, capacity: 14000, inflowRate: 0,
|
||||
overflowAllocations: [
|
||||
{ targetId: "bcrg", percentage: 100, color: OVERFLOW_COLORS[3] },
|
||||
|
|
@ -109,7 +109,7 @@ export const demoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "alice", type: "funnel", position: { x: -100, y: 1250 },
|
||||
data: {
|
||||
label: "Alice — Research", currentValue: 0, drainRate: 1200,
|
||||
label: "Alice — Research", currentValue: 0, drainRate: 800,
|
||||
overflowThreshold: 7200, capacity: 10800, inflowRate: 0,
|
||||
overflowAllocations: [
|
||||
{ targetId: "bob", percentage: 100, color: OVERFLOW_COLORS[4] },
|
||||
|
|
@ -123,7 +123,7 @@ export const demoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "bob", type: "funnel", position: { x: 280, y: 1250 },
|
||||
data: {
|
||||
label: "Bob — Engineering", currentValue: 0, drainRate: 1300,
|
||||
label: "Bob — Engineering", currentValue: 0, drainRate: 850,
|
||||
overflowThreshold: 7800, capacity: 11700, inflowRate: 0,
|
||||
overflowAllocations: [
|
||||
{ targetId: "programs", percentage: 100, color: OVERFLOW_COLORS[5] },
|
||||
|
|
@ -137,7 +137,7 @@ export const demoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "carol", type: "funnel", position: { x: 660, y: 1250 },
|
||||
data: {
|
||||
label: "Carol — Comms", currentValue: 0, drainRate: 1100,
|
||||
label: "Carol — Comms", currentValue: 0, drainRate: 750,
|
||||
overflowThreshold: 6600, capacity: 9900, inflowRate: 0,
|
||||
overflowAllocations: [
|
||||
{ targetId: "dave", percentage: 100, color: OVERFLOW_COLORS[0] },
|
||||
|
|
@ -151,7 +151,7 @@ export const demoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "dave", type: "funnel", position: { x: 1040, y: 1250 },
|
||||
data: {
|
||||
label: "Dave — Design", currentValue: 0, drainRate: 1000,
|
||||
label: "Dave — Design", currentValue: 0, drainRate: 650,
|
||||
overflowThreshold: 6000, capacity: 9000, inflowRate: 0,
|
||||
overflowAllocations: [
|
||||
{ targetId: "operations", percentage: 100, color: OVERFLOW_COLORS[1] },
|
||||
|
|
@ -165,7 +165,7 @@ export const demoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "eve", type: "funnel", position: { x: 1420, y: 1250 },
|
||||
data: {
|
||||
label: "Eve — Governance", currentValue: 0, drainRate: 900,
|
||||
label: "Eve — Governance", currentValue: 0, drainRate: 600,
|
||||
overflowThreshold: 5400, capacity: 8100, inflowRate: 0,
|
||||
overflowAllocations: [
|
||||
{ targetId: "bcrg", percentage: 100, color: OVERFLOW_COLORS[2] },
|
||||
|
|
@ -396,7 +396,7 @@ export const simDemoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "treasury", type: "funnel", position: { x: 500, y: 0 },
|
||||
data: {
|
||||
label: "Treasury", currentValue: 0, drainRate: 4000,
|
||||
label: "Treasury", currentValue: 5000, drainRate: 4000,
|
||||
overflowThreshold: 24000, capacity: 36000, inflowRate: 16000,
|
||||
overflowAllocations: [
|
||||
{ targetId: "reserve", percentage: 100, color: OVERFLOW_COLORS[0] },
|
||||
|
|
@ -415,7 +415,7 @@ export const simDemoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "ops", type: "funnel", position: { x: 80, y: 600 },
|
||||
data: {
|
||||
label: "Operations", currentValue: 0, drainRate: 1500,
|
||||
label: "Operations", currentValue: 0, drainRate: 1100,
|
||||
overflowThreshold: 9000, capacity: 13500, inflowRate: 0,
|
||||
overflowAllocations: [
|
||||
{ targetId: "community", percentage: 100, color: OVERFLOW_COLORS[0] },
|
||||
|
|
@ -429,7 +429,7 @@ export const simDemoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "research", type: "funnel", position: { x: 500, y: 600 },
|
||||
data: {
|
||||
label: "Research", currentValue: 0, drainRate: 1400,
|
||||
label: "Research", currentValue: 0, drainRate: 1000,
|
||||
overflowThreshold: 8400, capacity: 12600, inflowRate: 0,
|
||||
overflowAllocations: [
|
||||
{ targetId: "community", percentage: 100, color: OVERFLOW_COLORS[1] },
|
||||
|
|
@ -443,7 +443,7 @@ export const simDemoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "community", type: "funnel", position: { x: 920, y: 600 },
|
||||
data: {
|
||||
label: "Community", currentValue: 0, drainRate: 1000,
|
||||
label: "Community", currentValue: 0, drainRate: 700,
|
||||
overflowThreshold: 6000, capacity: 9000, inflowRate: 0,
|
||||
overflowAllocations: [
|
||||
{ targetId: "reserve", percentage: 100, color: OVERFLOW_COLORS[2] },
|
||||
|
|
@ -458,7 +458,7 @@ export const simDemoNodes: FlowNode[] = [
|
|||
id: "reserve", type: "funnel", position: { x: 1340, y: 600 },
|
||||
data: {
|
||||
label: "Reserve Fund", currentValue: 0, drainRate: 500,
|
||||
overflowThreshold: 10000, capacity: 20000, inflowRate: 0,
|
||||
overflowThreshold: 5000, capacity: 20000, inflowRate: 0,
|
||||
overflowAllocations: [
|
||||
{ targetId: "treasury", percentage: 100, color: OVERFLOW_COLORS[3] },
|
||||
],
|
||||
|
|
@ -474,7 +474,7 @@ export const simDemoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "infra-team", type: "funnel", position: { x: -100, y: 1250 },
|
||||
data: {
|
||||
label: "Infra Team", currentValue: 0, drainRate: 800,
|
||||
label: "Infra Team", currentValue: 0, drainRate: 550,
|
||||
overflowThreshold: 4800, capacity: 7200, inflowRate: 0,
|
||||
overflowAllocations: [
|
||||
{ targetId: "admin-team", percentage: 100, color: OVERFLOW_COLORS[4] },
|
||||
|
|
@ -488,7 +488,7 @@ export const simDemoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "admin-team", type: "funnel", position: { x: 280, y: 1250 },
|
||||
data: {
|
||||
label: "Admin Team", currentValue: 0, drainRate: 700,
|
||||
label: "Admin Team", currentValue: 0, drainRate: 500,
|
||||
overflowThreshold: 4200, capacity: 6300, inflowRate: 0,
|
||||
overflowAllocations: [
|
||||
{ targetId: "ops", percentage: 100, color: OVERFLOW_COLORS[5] },
|
||||
|
|
@ -502,7 +502,7 @@ export const simDemoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "science-team", type: "funnel", position: { x: 660, y: 1250 },
|
||||
data: {
|
||||
label: "Science Team", currentValue: 0, drainRate: 900,
|
||||
label: "Science Team", currentValue: 0, drainRate: 600,
|
||||
overflowThreshold: 5400, capacity: 8100, inflowRate: 0,
|
||||
overflowAllocations: [
|
||||
{ targetId: "tools-team", percentage: 100, color: OVERFLOW_COLORS[0] },
|
||||
|
|
@ -516,7 +516,7 @@ export const simDemoNodes: FlowNode[] = [
|
|||
{
|
||||
id: "tools-team", type: "funnel", position: { x: 1040, y: 1250 },
|
||||
data: {
|
||||
label: "Tools Team", currentValue: 0, drainRate: 600,
|
||||
label: "Tools Team", currentValue: 0, drainRate: 400,
|
||||
overflowThreshold: 3600, capacity: 5400, inflowRate: 0,
|
||||
overflowAllocations: [
|
||||
{ targetId: "research", percentage: 100, color: OVERFLOW_COLORS[1] },
|
||||
|
|
|
|||
|
|
@ -10,11 +10,14 @@ export interface SimulationConfig {
|
|||
}
|
||||
|
||||
export const DEFAULT_CONFIG: SimulationConfig = {
|
||||
tickDivisor: 10,
|
||||
tickDivisor: 5,
|
||||
};
|
||||
|
||||
export function computeSufficiencyState(data: FunnelNodeData): SufficiencyState {
|
||||
return data.currentValue >= data.overflowThreshold ? "overflowing" : "seeking";
|
||||
const ratio = data.currentValue / (data.overflowThreshold || 1);
|
||||
if (ratio >= 1) return "overflowing";
|
||||
if (ratio >= 0.8) return "approaching";
|
||||
return "seeking";
|
||||
}
|
||||
|
||||
export function computeSystemSufficiency(nodes: FlowNode[]): number {
|
||||
|
|
@ -37,10 +40,13 @@ export function computeSystemSufficiency(nodes: FlowNode[]): number {
|
|||
}
|
||||
|
||||
/**
|
||||
* Sync source→funnel allocations into each funnel's inflowRate.
|
||||
* Funnels with no source wired keep their manual inflowRate (backward compat).
|
||||
* Multi-pass inflow rate computation.
|
||||
* Pass 1: source→funnel direct allocations.
|
||||
* Pass 2+: propagate spending drain shares + overflow excess shares downstream.
|
||||
* Loops until convergence (max 20 iterations, delta < 0.001).
|
||||
*/
|
||||
export function computeInflowRates(nodes: FlowNode[]): FlowNode[] {
|
||||
// Pass 1: source → funnel direct allocations
|
||||
const computed = new Map<string, number>();
|
||||
for (const n of nodes) {
|
||||
if (n.type === "source") {
|
||||
|
|
@ -53,6 +59,52 @@ export function computeInflowRates(nodes: FlowNode[]): FlowNode[] {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2+: propagate spending + overflow downstream through funnel layers
|
||||
const funnelNodes = nodes.filter((n) => n.type === "funnel").sort((a, b) => a.position.y - b.position.y);
|
||||
for (let iter = 0; iter < 20; iter++) {
|
||||
let delta = 0;
|
||||
for (const fn of funnelNodes) {
|
||||
const d = fn.data as FunnelNodeData;
|
||||
const inflow = computed.get(fn.id) ?? d.inflowRate;
|
||||
if (inflow <= 0) continue;
|
||||
|
||||
// Spending drain goes downstream
|
||||
const drain = Math.min(d.drainRate, inflow);
|
||||
for (const alloc of d.spendingAllocations) {
|
||||
const share = drain * (alloc.percentage / 100);
|
||||
// Only propagate to other funnels
|
||||
const target = nodes.find((n) => n.id === alloc.targetId);
|
||||
if (target?.type === "funnel") {
|
||||
const prev = computed.get(alloc.targetId) ?? 0;
|
||||
const next = prev + share;
|
||||
if (Math.abs(next - prev) > 0.001) {
|
||||
computed.set(alloc.targetId, next);
|
||||
delta += Math.abs(next - prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overflow excess goes downstream (estimate: inflow - drain when above threshold)
|
||||
const netExcess = Math.max(0, inflow - drain);
|
||||
if (netExcess > 0) {
|
||||
for (const alloc of d.overflowAllocations) {
|
||||
const share = netExcess * (alloc.percentage / 100);
|
||||
const target = nodes.find((n) => n.id === alloc.targetId);
|
||||
if (target?.type === "funnel") {
|
||||
const prev = computed.get(alloc.targetId) ?? 0;
|
||||
const next = prev + share;
|
||||
if (Math.abs(next - prev) > 0.001) {
|
||||
computed.set(alloc.targetId, next);
|
||||
delta += Math.abs(next - prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (delta < 0.001) break;
|
||||
}
|
||||
|
||||
return nodes.map((n) => {
|
||||
if (n.type === "funnel" && computed.has(n.id)) {
|
||||
return { ...n, data: { ...n.data, inflowRate: computed.get(n.id)! } };
|
||||
|
|
@ -62,13 +114,10 @@ export function computeInflowRates(nodes: FlowNode[]): FlowNode[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* Conservation-enforcing tick: for each funnel (Y-order), compute:
|
||||
* 1. inflow = inflowRate / tickDivisor + overflow from upstream
|
||||
* 2. drain = min(drainRate / tickDivisor, currentValue + inflow)
|
||||
* 3. newValue = currentValue + inflow - drain
|
||||
* 4. if newValue > overflowThreshold → route excess to overflow targets
|
||||
* 5. distribute drain to spending targets
|
||||
* 6. clamp to [0, capacity]
|
||||
* Conservation-enforcing tick with convergence loop for circular overflow.
|
||||
*
|
||||
* Wraps funnel processing in up to 3 passes per tick so that upward overflow
|
||||
* (e.g. Growth→Treasury) doesn't suffer a 1-tick delay.
|
||||
*/
|
||||
export function simulateTick(
|
||||
nodes: FlowNode[],
|
||||
|
|
@ -80,21 +129,51 @@ export function simulateTick(
|
|||
.filter((n) => n.type === "funnel")
|
||||
.sort((a, b) => a.position.y - b.position.y);
|
||||
|
||||
const funnelIds = new Set(funnelNodes.map((n) => n.id));
|
||||
const overflowIncoming = new Map<string, number>();
|
||||
const spendingIncoming = new Map<string, number>();
|
||||
const updatedFunnels = new Map<string, FunnelNodeData>();
|
||||
|
||||
// Initialize funnel data
|
||||
for (const node of funnelNodes) {
|
||||
const src = node.data as FunnelNodeData;
|
||||
const data: FunnelNodeData = { ...src };
|
||||
updatedFunnels.set(node.id, { ...(node.data as FunnelNodeData) });
|
||||
}
|
||||
|
||||
// Convergence loop: re-process funnels that receive new overflow after being processed
|
||||
const processed = new Set<string>();
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
const needsProcessing = pass === 0
|
||||
? funnelNodes
|
||||
: funnelNodes.filter((n) => {
|
||||
// Only re-process if new overflow arrived after we processed it
|
||||
const incoming = overflowIncoming.get(n.id) ?? 0;
|
||||
return incoming > 0 && processed.has(n.id);
|
||||
});
|
||||
|
||||
if (pass > 0 && needsProcessing.length === 0) break;
|
||||
|
||||
for (const node of needsProcessing) {
|
||||
const data = pass === 0
|
||||
? updatedFunnels.get(node.id)!
|
||||
: { ...updatedFunnels.get(node.id)! };
|
||||
|
||||
// 1. Inflow: source rate + overflow received from upstream this tick
|
||||
const inflow = data.inflowRate / tickDivisor + (overflowIncoming.get(node.id) ?? 0);
|
||||
const inflow = (pass === 0 ? data.inflowRate / tickDivisor : 0)
|
||||
+ (overflowIncoming.get(node.id) ?? 0);
|
||||
|
||||
if (pass > 0) {
|
||||
// Clear consumed overflow
|
||||
overflowIncoming.delete(node.id);
|
||||
}
|
||||
|
||||
let value = data.currentValue + inflow;
|
||||
|
||||
// 2. Drain: flat rate capped by available funds
|
||||
const drain = Math.min(data.drainRate / tickDivisor, value);
|
||||
// 2. Drain: flat rate capped by available funds (only on first pass)
|
||||
let drain = 0;
|
||||
if (pass === 0) {
|
||||
drain = Math.min(data.drainRate / tickDivisor, value);
|
||||
value -= drain;
|
||||
}
|
||||
|
||||
// 3. Overflow: route excess above threshold to downstream
|
||||
if (value > data.overflowThreshold && data.overflowAllocations.length > 0) {
|
||||
|
|
@ -106,17 +185,24 @@ export function simulateTick(
|
|||
value = data.overflowThreshold;
|
||||
}
|
||||
|
||||
// 4. Distribute drain to spending targets
|
||||
// 4. Distribute drain to spending targets (funnel or outcome)
|
||||
if (drain > 0 && data.spendingAllocations.length > 0) {
|
||||
for (const alloc of data.spendingAllocations) {
|
||||
const share = drain * (alloc.percentage / 100);
|
||||
if (funnelIds.has(alloc.targetId)) {
|
||||
// Spending to another funnel: add as overflow incoming for convergence
|
||||
overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share);
|
||||
} else {
|
||||
spendingIncoming.set(alloc.targetId, (spendingIncoming.get(alloc.targetId) ?? 0) + share);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Clamp
|
||||
data.currentValue = Math.max(0, Math.min(value, data.capacity));
|
||||
updatedFunnels.set(node.id, data);
|
||||
processed.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Process outcomes in Y-order so overflow can cascade
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export interface SourceAllocation {
|
|||
waypoint?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export type SufficiencyState = "seeking" | "overflowing";
|
||||
export type SufficiencyState = "seeking" | "approaching" | "overflowing";
|
||||
|
||||
export interface FunnelNodeData {
|
||||
label: string;
|
||||
|
|
|
|||
|
|
@ -243,6 +243,7 @@ function storeSession(token: string, username: string, did: string): void {
|
|||
// ── Persona helpers (client-side multi-account) ──
|
||||
|
||||
const PERSONAS_KEY = "rspace-known-personas";
|
||||
const KNOWN_ACCOUNTS_KEY = "encryptid-known-accounts";
|
||||
|
||||
interface KnownPersona {
|
||||
username: string;
|
||||
|
|
@ -255,6 +256,30 @@ function getKnownPersonas(): KnownPersona[] {
|
|||
} catch { return []; }
|
||||
}
|
||||
|
||||
/** Merge personas from both localStorage keys into a deduplicated list by username. */
|
||||
function getAllKnownUsernames(): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
// Primary: persona list (has DID, most reliable)
|
||||
for (const p of getKnownPersonas()) {
|
||||
if (p.username && !seen.has(p.username)) {
|
||||
seen.add(p.username);
|
||||
result.push(p.username);
|
||||
}
|
||||
}
|
||||
// Secondary: encryptid login-button known accounts
|
||||
try {
|
||||
const accounts: { username: string }[] = JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || "[]");
|
||||
for (const a of accounts) {
|
||||
if (a.username && !seen.has(a.username)) {
|
||||
seen.add(a.username);
|
||||
result.push(a.username);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
function addKnownPersona(username: string, did: string): void {
|
||||
const personas = getKnownPersonas();
|
||||
const idx = personas.findIndex(p => p.did === did);
|
||||
|
|
@ -264,6 +289,14 @@ function addKnownPersona(username: string, did: string): void {
|
|||
personas.push({ username, did });
|
||||
}
|
||||
localStorage.setItem(PERSONAS_KEY, JSON.stringify(personas));
|
||||
// Also sync to encryptid-known-accounts for login-button compat
|
||||
try {
|
||||
const accounts: { username: string; displayName?: string }[] = JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || "[]");
|
||||
if (!accounts.some(a => a.username === username)) {
|
||||
accounts.unshift({ username });
|
||||
localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function removeKnownPersona(did: string): void {
|
||||
|
|
@ -790,21 +823,60 @@ export class RStackIdentity extends HTMLElement {
|
|||
attachListeners();
|
||||
};
|
||||
|
||||
const signinHTML = () => `
|
||||
<style>${MODAL_STYLES}</style>
|
||||
let showManualInput = false;
|
||||
|
||||
const signinHTML = () => {
|
||||
const knownUsers = usernameHint ? [] : getAllKnownUsernames();
|
||||
const showPicker = knownUsers.length > 0 && !showManualInput;
|
||||
|
||||
const accountButtons = knownUsers.map(u => {
|
||||
const initial = u[0]?.toUpperCase() || "?";
|
||||
const escaped = u.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
return `<button class="account-pick-btn" data-pick-username="${escaped}">
|
||||
<span class="account-pick-avatar">${initial}</span>
|
||||
<span class="account-pick-name">${escaped}</span>
|
||||
<span class="account-pick-arrow">→</span>
|
||||
</button>`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<style>${MODAL_STYLES}
|
||||
.account-picker { display: flex; flex-direction: column; gap: 8px; margin-bottom: 1rem; }
|
||||
.account-pick-btn {
|
||||
display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 16px;
|
||||
background: var(--rs-bg-hover); border: 1px solid var(--rs-border); border-radius: 10px;
|
||||
color: var(--rs-text-primary); cursor: pointer; transition: all 0.2s;
|
||||
font-size: 0.95rem; font-family: inherit; text-align: left;
|
||||
}
|
||||
.account-pick-btn:hover { border-color: #06b6d4; background: rgba(6,182,212,0.08); transform: translateY(-1px); }
|
||||
.account-pick-avatar {
|
||||
width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white;
|
||||
display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 0.85rem;
|
||||
}
|
||||
.account-pick-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
||||
.account-pick-arrow { color: var(--rs-text-muted); font-size: 1.1rem; }
|
||||
.alt-link { display: block; text-align: center; margin-top: 8px; font-size: 0.85rem; color: var(--rs-text-muted); cursor: pointer; background: none; border: none; font-family: inherit; width: 100%; padding: 4px; }
|
||||
.alt-link:hover { color: #06b6d4; text-decoration: underline; }
|
||||
</style>
|
||||
<div class="auth-modal">
|
||||
<button class="close-btn" data-action="cancel">×</button>
|
||||
<h2>Sign up / Sign in</h2>
|
||||
<p>Secure, passwordless authentication powered by passkeys.</p>
|
||||
<p>${showPicker ? "Choose an account on this device:" : "Secure, passwordless authentication powered by passkeys."}</p>
|
||||
${showPicker ? `
|
||||
<div class="account-picker">${accountButtons}</div>
|
||||
<button class="alt-link" data-action="show-manual">Use a different account</button>
|
||||
` : `
|
||||
<input class="input" id="auth-signin-username" type="text" placeholder="Username or email" autocomplete="username webauthn" maxlength="64" />
|
||||
`}
|
||||
<div class="actions actions--stack">
|
||||
<button class="btn btn--primary" data-action="signin">🔑 Sign In with Passkey</button>
|
||||
${showPicker ? '' : `<button class="btn btn--primary" data-action="signin">🔑 Sign In with Passkey</button>`}
|
||||
<button class="btn btn--outline" data-action="switch-register">🔐 Create New Account</button>
|
||||
</div>
|
||||
<div class="error" id="auth-error"></div>
|
||||
<div class="learn-more">Powered by <a href="https://ridentity.online" target="_blank" rel="noopener">EncryptID</a></div>
|
||||
</div>
|
||||
`;
|
||||
`;};
|
||||
|
||||
const registerHTML = () => `
|
||||
<style>${MODAL_STYLES}</style>
|
||||
|
|
@ -838,7 +910,10 @@ export class RStackIdentity extends HTMLElement {
|
|||
usernameInput?.focus();
|
||||
return;
|
||||
}
|
||||
// Show loading state on either the signin button or the clicked picker button
|
||||
const pickerBtn = overlay.querySelector(`[data-pick-username="${CSS.escape(loginUsername)}"]`) as HTMLButtonElement | null;
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Authenticating...'; }
|
||||
if (pickerBtn) { pickerBtn.style.opacity = "0.6"; pickerBtn.style.pointerEvents = "none"; const arrow = pickerBtn.querySelector(".account-pick-arrow"); if (arrow) arrow.innerHTML = '<span class="spinner" style="width:14px;height:14px;border-width:2px"></span>'; }
|
||||
|
||||
try {
|
||||
const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, {
|
||||
|
|
@ -893,6 +968,7 @@ export class RStackIdentity extends HTMLElement {
|
|||
autoResolveSpace(data.token, data.username || "");
|
||||
} catch (err: any) {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = "🔑 Sign In with Passkey"; }
|
||||
if (pickerBtn) { pickerBtn.style.opacity = ""; pickerBtn.style.pointerEvents = ""; const arrow = pickerBtn.querySelector(".account-pick-arrow"); if (arrow) arrow.innerHTML = "→"; }
|
||||
const msg = err.name === "NotAllowedError" ? "Authentication was cancelled." : err.message || "Authentication failed.";
|
||||
if (errEl) errEl.textContent = msg;
|
||||
// If auto-triggered persona switch was cancelled, close modal and restore previous state
|
||||
|
|
@ -989,8 +1065,34 @@ export class RStackIdentity extends HTMLElement {
|
|||
});
|
||||
overlay.querySelector('[data-action="switch-signin"]')?.addEventListener("click", () => {
|
||||
mode = "signin";
|
||||
showManualInput = false;
|
||||
render();
|
||||
});
|
||||
overlay.querySelector('[data-action="show-manual"]')?.addEventListener("click", () => {
|
||||
showManualInput = true;
|
||||
render();
|
||||
setTimeout(() => (overlay.querySelector("#auth-signin-username") as HTMLInputElement)?.focus(), 50);
|
||||
});
|
||||
// Account picker buttons — click triggers sign-in with that username
|
||||
overlay.querySelectorAll("[data-pick-username]").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const picked = (btn as HTMLElement).dataset.pickUsername || "";
|
||||
if (!picked) return;
|
||||
// Populate a hidden username for handleSignIn, then trigger it
|
||||
const input = overlay.querySelector("#auth-signin-username") as HTMLInputElement | null;
|
||||
if (input) {
|
||||
input.value = picked;
|
||||
} else {
|
||||
// No input in DOM (picker mode) — create a temp hidden one for handleSignIn
|
||||
const tmp = document.createElement("input");
|
||||
tmp.id = "auth-signin-username";
|
||||
tmp.type = "hidden";
|
||||
tmp.value = picked;
|
||||
overlay.querySelector(".auth-modal")?.appendChild(tmp);
|
||||
}
|
||||
handleSignIn();
|
||||
});
|
||||
});
|
||||
overlay.querySelector("#auth-username")?.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") handleRegister();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue