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