diff --git a/backlog/tasks/task-120 - Universal-Profiles-x-EncryptID-integration.md b/backlog/tasks/task-120 - Universal-Profiles-x-EncryptID-integration.md deleted file mode 100644 index f0d502e5..00000000 --- a/backlog/tasks/task-120 - Universal-Profiles-x-EncryptID-integration.md +++ /dev/null @@ -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 diff --git a/backlog/tasks/task-120 - Universal-Profiles-×-EncryptID-integration.md b/backlog/tasks/task-120 - Universal-Profiles-×-EncryptID-integration.md new file mode 100644 index 00000000..b99b8f6e --- /dev/null +++ b/backlog/tasks/task-120 - Universal-Profiles-×-EncryptID-integration.md @@ -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 + + +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 + + +## 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 + + +**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. + diff --git a/backlog/tasks/task-142 - miC-—-Voice-Conversation-Mode-for-MI-Agent.md b/backlog/tasks/task-142 - miC-—-Voice-Conversation-Mode-for-MI-Agent.md new file mode 100644 index 00000000..7ff93a95 --- /dev/null +++ b/backlog/tasks/task-142 - miC-—-Voice-Conversation-Mode-for-MI-Agent.md @@ -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 + + +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 + diff --git a/backlog/tasks/task-143 - Customizable-Dashboard-with-Persistent-Home-Icon.md b/backlog/tasks/task-143 - Customizable-Dashboard-with-Persistent-Home-Icon.md new file mode 100644 index 00000000..d492fe9e --- /dev/null +++ b/backlog/tasks/task-143 - Customizable-Dashboard-with-Persistent-Home-Icon.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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 + + +## Final Summary + + +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. + diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index b19dade5..f30e022e 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -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; diff --git a/modules/rflows/components/folk-flow-river.ts b/modules/rflows/components/folk-flow-river.ts index c2c3f0d2..6fb49137 100644 --- a/modules/rflows/components/folk-flow-river.ts +++ b/modules/rflows/components/folk-flow-river.ts @@ -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; - const cp1y = b.y1 + dy * 0.15; - const cp2y = b.y2 - dy * 0.15; + let path: string; + let center: string; + let midX: number; + let midY: number; - // Left edge and right edge of ribbon - const 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(" "); + if (dy > 20) { + // Downward flows: existing cubic bezier ribbon + const cp1y = b.y1 + dy * 0.15; + const cp2y = b.y2 - dy * 0.15; + 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 ` - - - + + + ${b.label}`; } @@ -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 ? `` : ""; + return ` + ${approachingGlow} ${fillPath ? `` : ""} ${esc(f.label)} - ${fmtDollars(val)} | drain ${fmtDollars(drain)}/mo + ${fmtDollars(val)} `; } @@ -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 } }
diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 825bd91c..015c0eaf 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -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 `
@@ -1068,6 +1069,18 @@ class FolkFlowsApp extends HTMLElement { + + + + + + + + + + + + @@ -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 { ${isOverflow ? `` : ""} + ${isApproaching ? `` : ""} - ${fillPath ? `` : ""} + ${fillPath ? `` : ""} ${shimmerLine} ${thresholdLines} @@ -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 = ``; @@ -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}`; - midX = (x1 + outwardX + x2) / 3; - midY = (y1 + y2) / 2; + + 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); diff --git a/modules/rflows/lib/presets.ts b/modules/rflows/lib/presets.ts index 5d31f42b..c3e4b2a3 100644 --- a/modules/rflows/lib/presets.ts +++ b/modules/rflows/lib/presets.ts @@ -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] }, diff --git a/modules/rflows/lib/simulation.ts b/modules/rflows/lib/simulation.ts index bad82986..adfb261d 100644 --- a/modules/rflows/lib/simulation.ts +++ b/modules/rflows/lib/simulation.ts @@ -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(); 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,43 +129,80 @@ 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(); const spendingIncoming = new Map(); const updatedFunnels = new Map(); + // 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) }); + } - // 1. Inflow: source rate + overflow received from upstream this tick - const inflow = data.inflowRate / tickDivisor + (overflowIncoming.get(node.id) ?? 0); - let value = data.currentValue + inflow; + // Convergence loop: re-process funnels that receive new overflow after being processed + const processed = new Set(); + 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); + }); - // 2. Drain: flat rate capped by available funds - const drain = Math.min(data.drainRate / tickDivisor, value); - value -= drain; + if (pass > 0 && needsProcessing.length === 0) break; - // 3. Overflow: route excess above threshold to downstream - if (value > data.overflowThreshold && data.overflowAllocations.length > 0) { - const excess = value - data.overflowThreshold; - for (const alloc of data.overflowAllocations) { - const share = excess * (alloc.percentage / 100); - overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share); + 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 = (pass === 0 ? data.inflowRate / tickDivisor : 0) + + (overflowIncoming.get(node.id) ?? 0); + + if (pass > 0) { + // Clear consumed overflow + overflowIncoming.delete(node.id); } - value = data.overflowThreshold; - } - // 4. Distribute drain to spending targets - if (drain > 0 && data.spendingAllocations.length > 0) { - for (const alloc of data.spendingAllocations) { - const share = drain * (alloc.percentage / 100); - spendingIncoming.set(alloc.targetId, (spendingIncoming.get(alloc.targetId) ?? 0) + share); + let value = data.currentValue + inflow; + + // 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; } - } - // 5. Clamp - data.currentValue = Math.max(0, Math.min(value, data.capacity)); - updatedFunnels.set(node.id, data); + // 3. Overflow: route excess above threshold to downstream + if (value > data.overflowThreshold && data.overflowAllocations.length > 0) { + const excess = value - data.overflowThreshold; + for (const alloc of data.overflowAllocations) { + const share = excess * (alloc.percentage / 100); + overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share); + } + value = data.overflowThreshold; + } + + // 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 diff --git a/modules/rflows/lib/types.ts b/modules/rflows/lib/types.ts index 04621850..c9a3c288 100644 --- a/modules/rflows/lib/types.ts +++ b/modules/rflows/lib/types.ts @@ -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; diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index 1e2e7176..1eb7104e 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -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(); + 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 = () => ` - + 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, """); + return ``; + }).join(""); + + return ` +

Sign up / Sign in

-

Secure, passwordless authentication powered by passkeys.

- +

${showPicker ? "Choose an account on this device:" : "Secure, passwordless authentication powered by passkeys."}

+ ${showPicker ? ` + + + ` : ` + + `}
- + ${showPicker ? '' : ``}
Powered by EncryptID
- `; + `;}; const registerHTML = () => ` @@ -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 = ' Authenticating...'; } + if (pickerBtn) { pickerBtn.style.opacity = "0.6"; pickerBtn.style.pointerEvents = "none"; const arrow = pickerBtn.querySelector(".account-pick-arrow"); if (arrow) arrow.innerHTML = ''; } 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(); });