From 2cb1ff092bb9d85b470c898cfc41e558fcfe25a3 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 12 Mar 2026 11:24:41 -0700 Subject: [PATCH] feat(shell): redesign rApp info popup as centered modal with overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix z-index (9000 → 10001) so popup renders above header and tab bar - Center popup as a proper modal with blurred backdrop overlay - Header now shows module emoji icon + name (fetched from API) - Bigger, bolder CTA buttons with gradient fills and hover effects - Tour/guide links auto-promoted to prominent purple-accent buttons - Loading spinner animation, Escape key to dismiss, click-outside-to-close - Mobile: slides up from bottom instead of top-right corner - Also: rcart/rwallet subnav route support, rcart tour simplification Co-Authored-By: Claude Opus 4.6 --- modules/rcart/components/folk-cart-shop.ts | 23 +- modules/rcart/mod.ts | 23 +- .../rnetwork/components/folk-graph-viewer.ts | 87 +++++-- modules/rnetwork/mod.ts | 104 +++++--- .../rwallet/components/folk-wallet-viewer.ts | 6 + modules/rwallet/mod.ts | 21 +- server/index.ts | 2 +- server/shell.ts | 224 +++++++++++------- 8 files changed, 321 insertions(+), 169 deletions(-) diff --git a/modules/rcart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts index cb78f94..8adbcac 100644 --- a/modules/rcart/components/folk-cart-shop.ts +++ b/modules/rcart/components/folk-cart-shop.ts @@ -41,12 +41,7 @@ class FolkCartShop extends HTMLElement { // Guided tour private _tour!: TourEngine; private static readonly TOUR_STEPS = [ - { target: '[data-view="carts"]', title: "Carts", message: "View and manage group shopping carts. Each cart collects items from multiple contributors.", advanceOnClick: true }, - { target: "[data-action='new-cart']", title: "Create a Cart", message: "Start a new group cart — add a name and invite contributors to add items.", advanceOnClick: true }, - { target: '[data-view="catalog"]', title: "Catalog", message: "Browse the cosmolocal catalog of available products and add them to your carts.", advanceOnClick: true }, - { target: '[data-view="group-buys"]', title: "Group Buys", message: "Join ongoing group buys to unlock volume pricing. The more people pledge, the cheaper it gets for everyone.", advanceOnClick: true }, - { target: '[data-view="orders"]', title: "Orders", message: "Track submitted orders and their status.", advanceOnClick: true }, - { target: '[data-view="payments"]', title: "Payments", message: "Create shareable payment requests with QR codes. Click to finish the tour!", advanceOnClick: true }, + { target: "[data-action='new-cart']", title: "Create a Cart", message: "Start a new group cart — add a name and invite contributors to add items. Use the navigation bar above to switch between Carts, Catalog, Group Buys, Orders, and Payments.", advanceOnClick: true }, ]; constructor() { @@ -473,20 +468,6 @@ class FolkCartShop extends HTMLElement { } private bindEvents() { - // Tab switching - this.shadow.querySelectorAll(".tab").forEach((el) => { - el.addEventListener("click", () => { - const v = (el as HTMLElement).dataset.view as any; - if (v && v !== this.view) { - this._history.push(this.view); - this._history.push(v); - } - this.view = v; - if (v === 'carts') this.selectedCartId = null; - this.render(); - }); - }); - // Tour button this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour()); @@ -1025,7 +1006,7 @@ class FolkCartShop extends HTMLElement { if (this.groupBuys.length === 0) { return `

No group buys yet. Start one from any catalog item to unlock volume pricing together.

- + Browse Catalog
`; } diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index d406aae..b13448b 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -1634,20 +1634,29 @@ routes.get("/pay/:id", (c) => { })); }); -// ── Page route: shop ── -routes.get("/", (c) => { - const space = c.req.param("space") || "demo"; - return c.html(renderShell({ +// ── Page routes: shop views (subnav tab links) ── + +function renderShop(space: string, view?: string) { + const viewAttr = view ? ` initial-view="${view}"` : ""; + return renderShell({ title: `${space} — Shop | rSpace`, moduleId: "rcart", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - body: ``, + body: ``, scripts: ``, styles: ``, - })); -}); + }); +} + +routes.get("/carts", (c) => c.html(renderShop(c.req.param("space") || "demo", "carts"))); +routes.get("/catalog", (c) => c.html(renderShop(c.req.param("space") || "demo", "catalog"))); +routes.get("/orders", (c) => c.html(renderShop(c.req.param("space") || "demo", "orders"))); +routes.get("/group-buys", (c) => c.html(renderShop(c.req.param("space") || "demo", "group-buys"))); +routes.get("/subscriptions", (c) => c.html(renderShop(c.req.param("space") || "demo", "subscriptions"))); + +routes.get("/", (c) => c.html(renderShop(c.req.param("space") || "demo"))); // ── Seed template data ── diff --git a/modules/rnetwork/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts index b3860ec..fbd056c 100644 --- a/modules/rnetwork/components/folk-graph-viewer.ts +++ b/modules/rnetwork/components/folk-graph-viewer.ts @@ -9,7 +9,7 @@ interface GraphNode { id: string; name: string; - type: "person" | "company" | "opportunity" | "rspace_user"; + type: "person" | "company" | "opportunity" | "rspace_user" | "space"; workspace: string; role?: string; location?: string; @@ -48,6 +48,7 @@ const NODE_COLORS: Record = { company: 0x22c55e, opportunity: 0xf59e0b, rspace_user: 0xa78bfa, + space: 0x64748b, }; const COMPANY_PALETTE = [0x6366f1, 0x22c55e, 0xf59e0b, 0xec4899, 0x14b8a6, 0xf97316, 0x8b5cf6, 0x06b6d4]; @@ -58,6 +59,8 @@ const EDGE_STYLES: Record n.type === "person").length, company_count: this.nodes.filter(n => n.type === "company").length, + rspace_member_count: this.nodes.filter(n => n.type === "rspace_user").length, }; } private getFilteredNodes(): GraphNode[] { let filtered = this.nodes; if (this.filter !== "all") { - filtered = filtered.filter(n => n.type === this.filter); + if (this.filter === "rspace_user") { + // Include space hub nodes alongside members + filtered = filtered.filter(n => n.type === "rspace_user" || n.type === "space"); + } else { + filtered = filtered.filter(n => n.type === this.filter); + } } if (this.searchQuery.trim()) { const q = this.searchQuery.toLowerCase(); @@ -244,14 +253,17 @@ class FolkGraphViewer extends HTMLElement { private getNodeRadius(node: GraphNode): number { if (node.type === "company") return 22; + if (node.type === "space") return 16; + if (node.type === "rspace_user") { + if (this.trustMode) { + if (node.delegatedWeight != null) return 6 + node.delegatedWeight * 24; + if (node.trustScore != null) return 6 + node.trustScore * 24; + } + return 10; + } if (this.trustMode) { - // Prefer edge-computed delegatedWeight, fall back to trustScore - if (node.delegatedWeight != null) { - return 6 + node.delegatedWeight * 24; - } - if (node.trustScore != null) { - return 6 + node.trustScore * 24; - } + if (node.delegatedWeight != null) return 6 + node.delegatedWeight * 24; + if (node.trustScore != null) return 6 + node.trustScore * 24; } return 12; } @@ -442,6 +454,8 @@ class FolkGraphViewer extends HTMLElement {
People
Organizations
+ +
Works at
Point of contact
@@ -610,13 +624,39 @@ class FolkGraphViewer extends HTMLElement { this.updateDetailPanel(); this.updateGraphData(); // refresh highlight }) - .d3AlphaDecay(0.03) - .d3VelocityDecay(0.4) - .warmupTicks(80) - .cooldownTicks(200); + .d3AlphaDecay(0.02) + .d3VelocityDecay(0.3) + .warmupTicks(120) + .cooldownTicks(300); this.graph = graph; + // Custom d3 forces for better clustering and readability + // Stronger repulsion — hub nodes push harder + const chargeForce = graph.d3Force('charge'); + if (chargeForce) { + chargeForce.strength((node: GraphNode) => { + if (node.type === 'company' || node.type === 'space') return -300; + return -120; + }); + } + // Type-specific link distances + const linkForce = graph.d3Force('link'); + if (linkForce) { + linkForce.distance((link: any) => { + const type = link.type || ''; + switch (type) { + case 'work_at': return 40; + case 'member_of': return 60; + case 'member_is': return 20; + case 'delegates_to': return 50; + case 'collaborates': return 80; + case 'point_of_contact': return 70; + default: return 60; + } + }); + } + // Remap controls: LEFT=PAN, RIGHT=ROTATE, MIDDLE=DOLLY // THREE.MOUSE: ROTATE=0, DOLLY=1, PAN=2 const controls = graph.controls(); @@ -715,7 +755,7 @@ class FolkGraphViewer extends HTMLElement { } // Trust badge sprite - if (node.type !== "company") { + if (node.type !== "company" && node.type !== "space") { const trust = this.getTrustScore(node.id); if (trust >= 0) { const badge = this.createBadgeSprite(THREE, String(trust)); @@ -810,11 +850,16 @@ class FolkGraphViewer extends HTMLElement { links: filteredEdges, }); - // Update legend visibility for trust mode + // Update legend visibility — members always visible when present, delegations only in trust mode + const hasMembers = this.nodes.some(n => n.type === "rspace_user"); const membersLegend = this.shadow.getElementById("legend-members"); const delegatesLegend = this.shadow.getElementById("legend-delegates"); const authorityColors = this.shadow.getElementById("legend-authority-colors"); - if (membersLegend) membersLegend.style.display = this.trustMode ? "" : "none"; + const spaceLegend = this.shadow.getElementById("legend-space"); + const memberIsLegend = this.shadow.getElementById("legend-member-is"); + if (membersLegend) membersLegend.style.display = hasMembers ? "" : "none"; + if (spaceLegend) spaceLegend.style.display = hasMembers ? "" : "none"; + if (memberIsLegend) memberIsLegend.style.display = (hasMembers && this.edges.some(e => e.type === "member_is")) ? "" : "none"; if (delegatesLegend) delegatesLegend.style.display = (this.trustMode && this.authority !== "all") ? "" : "none"; if (authorityColors) authorityColors.style.display = (this.trustMode && this.authority === "all") ? "" : "none"; @@ -830,9 +875,11 @@ class FolkGraphViewer extends HTMLElement { const bar = this.shadow.getElementById("stats-bar"); if (!bar || !this.info) return; const crossOrg = this.edges.filter(e => e.type === "point_of_contact").length; + const memberCount = this.info.rspace_member_count || 0; bar.innerHTML = `
${this.info.member_count || 0}
People
${this.info.company_count || 0}
Organizations
+ ${memberCount > 0 ? `
${memberCount}
Members
` : ""}
${crossOrg}
Cross-org Links
`; } @@ -859,15 +906,15 @@ class FolkGraphViewer extends HTMLElement { const n = this.selectedNode; const connected = this.getConnectedNodes(n.id); - const trust = n.type !== "company" ? this.getTrustScore(n.id) : -1; + const trust = (n.type !== "company" && n.type !== "space") ? this.getTrustScore(n.id) : -1; panel.classList.add("visible"); panel.innerHTML = `
- ${n.type === "company" ? "\u{1F3E2}" : "\u{1F464}"} + ${n.type === "company" ? "\u{1F3E2}" : n.type === "space" ? "\u{1F310}" : "\u{1F464}"}
${this.esc(n.name)}
-
${this.esc(n.type === "company" ? "Organization" : n.role || "Person")}${n.location ? ` \u00b7 ${this.esc(n.location)}` : ""}
+
${this.esc(n.type === "company" ? "Organization" : n.type === "space" ? "Space" : n.type === "rspace_user" ? "Member" : n.role || "Person")}${n.location ? ` \u00b7 ${this.esc(n.location)}` : ""}
diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index fa6f226..8c4d4cf 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -200,7 +200,8 @@ routes.get("/api/graph", async (c) => { // Check per-space cache (keyed by space + trust params) const includeTrust = c.req.query("trust") === "true"; const authority = c.req.query("authority") || "gov-ops"; - const cacheKey = includeTrust ? `${dataSpace}:trust:${authority}` : dataSpace; + const trustSpace = c.req.param("space") || "demo"; + const cacheKey = includeTrust ? `${dataSpace}:${trustSpace}:trust:${authority}` : `${dataSpace}:${trustSpace}`; const cached = graphCaches.get(cacheKey); if (cached && Date.now() - cached.ts < CACHE_TTL) { c.header("Cache-Control", "public, max-age=60"); @@ -261,19 +262,80 @@ routes.get("/api/graph", async (c) => { } } - // If trust=true, merge EncryptID user nodes + delegation edges - // Trust data uses per-space scoping (not module's global scoping) + // Always fetch EncryptID members for space context + // Delegations + trust scores remain gated behind trust=true + const isAllAuthority = authority === "all"; + type UserEntry = { did: string; username: string; displayName: string | null; email?: string; trustScores: Record }; + let spaceUsers: UserEntry[] = []; + + try { + const usersRes = await fetch( + `${ENCRYPTID_URL}/api/users/directory?space=${encodeURIComponent(trustSpace)}`, + { signal: AbortSignal.timeout(5000) }, + ); + if (usersRes.ok) { + const userData = await usersRes.json() as { users: UserEntry[] }; + spaceUsers = userData.users || []; + } + } catch (e) { console.error("[Network] Member fetch error:", e); } + + if (spaceUsers.length > 0) { + // Add synthetic space hub node + const spaceHubId = `space:${trustSpace}`; + if (!nodeIds.has(spaceHubId)) { + nodes.push({ id: spaceHubId, label: trustSpace, type: "space" as any, data: {} }); + nodeIds.add(spaceHubId); + } + + // Collect CRM people for name/email matching + const crmPeople = nodes.filter(n => n.type === "person"); + + for (const u of spaceUsers) { + if (!nodeIds.has(u.did)) { + let trustScore = 0; + if (includeTrust) { + if (isAllAuthority && u.trustScores) { + const vals = Object.values(u.trustScores).filter(v => v > 0); + trustScore = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; + } else { + trustScore = u.trustScores?.[authority] ?? 0; + } + } + nodes.push({ + id: u.did, + label: u.displayName || u.username, + type: "rspace_user" as any, + data: { trustScore, authority: isAllAuthority ? "all" : authority, role: "member" }, + }); + nodeIds.add(u.did); + } + + // member_of edge: member → space hub + edges.push({ source: u.did, target: spaceHubId, type: "member_of" }); + + // Auto-match member to CRM person by name or email + const uName = (u.displayName || u.username || "").toLowerCase().trim(); + const uFirst = uName.split(/\s+/)[0]; + for (const crmNode of crmPeople) { + const crmName = crmNode.label.toLowerCase().trim(); + const crmFirst = crmName.split(/\s+/)[0]; + const nameMatch = uName && crmName && (uName === crmName || (uFirst.length > 2 && uFirst === crmFirst)); + const emailMatch = u.email && (crmNode.data as any)?.email && u.email.toLowerCase() === ((crmNode.data as any).email as string).toLowerCase(); + if (nameMatch || emailMatch) { + edges.push({ source: u.did, target: crmNode.id, type: "member_is" }); + } + } + } + } + + // If trust=true, also fetch delegation edges + trust scores if (includeTrust) { - const trustSpace = c.req.param("space") || "demo"; - const isAllAuthority = authority === "all"; try { - // For "all" mode: fetch delegations without authority filter, scores for all authorities const delegUrl = new URL(`${ENCRYPTID_URL}/api/delegations/space`); delegUrl.searchParams.set("space", trustSpace); if (!isAllAuthority) delegUrl.searchParams.set("authority", authority); const fetches: Promise[] = [ - fetch(`${ENCRYPTID_URL}/api/users/directory?space=${encodeURIComponent(trustSpace)}`, { signal: AbortSignal.timeout(5000) }), fetch(delegUrl, { signal: AbortSignal.timeout(5000) }), ]; if (isAllAuthority) { @@ -285,31 +347,9 @@ routes.get("/api/graph", async (c) => { } const responses = await Promise.all(fetches); - const [usersRes, delegRes, ...scoreResponses] = responses; + const [delegRes, ...scoreResponses] = responses; - if (usersRes.ok) { - const userData = await usersRes.json() as { users: Array<{ did: string; username: string; displayName: string | null; trustScores: Record }> }; - for (const u of userData.users || []) { - if (!nodeIds.has(u.did)) { - let trustScore = 0; - if (isAllAuthority && u.trustScores) { - const vals = Object.values(u.trustScores).filter(v => v > 0); - trustScore = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; - } else { - trustScore = u.trustScores?.[authority] ?? 0; - } - nodes.push({ - id: u.did, - label: u.displayName || u.username, - type: "rspace_user" as any, - data: { trustScore, authority: isAllAuthority ? "all" : authority, role: "member" }, - }); - nodeIds.add(u.did); - } - } - } - - // Merge trust scores from all score responses + // Merge trust scores const trustMap = new Map(); for (const scoresRes of scoreResponses) { if (scoresRes.ok) { @@ -326,7 +366,7 @@ routes.get("/api/graph", async (c) => { } } - // Add delegation edges (with authority tag for per-authority coloring) + // Add delegation edges if (delegRes.ok) { const delegData = await delegRes.json() as { delegations: Array<{ from: string; to: string; authority: string; weight: number }> }; for (const d of delegData.delegations || []) { diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index a327b45..be8b660 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -142,6 +142,12 @@ class FolkWalletViewer extends HTMLElement { } connectedCallback() { + // Read initial-view attribute from server route + const initialView = this.getAttribute("initial-view"); + if (initialView && ["balances", "timeline", "flow", "sankey"].includes(initialView)) { + this.activeView = initialView as ViewTab; + } + const space = this.getAttribute("space") || ""; if (space === "demo") { this.loadDemoData(); diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index d44996b..9f3109a 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -829,20 +829,27 @@ routes.post("/api/crdt-tokens/:tokenId/mint", async (c) => { }); // ── Page route ── -routes.get("/", (c) => { - const spaceSlug = c.req.param("space") || "demo"; - const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; - return c.html(renderShell({ +// ── Page routes: subnav tab links ── + +function renderWallet(spaceSlug: string, initialView?: string) { + const viewAttr = initialView ? ` initial-view="${initialView}"` : ""; + return renderShell({ title: `${spaceSlug} — Wallet | rSpace`, moduleId: "rwallet", spaceSlug, modules: getModuleInfoList(), theme: "dark", - body: ``, + body: ``, scripts: ``, styles: ``, - })); -}); + }); +} + +routes.get("/wallets", (c) => c.html(renderWallet(c.req.param("space") || "demo"))); +routes.get("/tokens", (c) => c.html(renderWallet(c.req.param("space") || "demo", "balances"))); +routes.get("/transactions", (c) => c.html(renderWallet(c.req.param("space") || "demo", "timeline"))); + +routes.get("/", (c) => c.html(renderWallet(c.req.param("space") || "demo"))); export const walletModule: RSpaceModule = { id: "rwallet", diff --git a/server/index.ts b/server/index.ts index d4d936a..b5320f7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -589,7 +589,7 @@ app.get("/api/modules/:moduleId/landing", (c) => { const mod = getModule(moduleId); if (!mod) return c.json({ error: "Module not found" }, 404); const html = mod.landingPage ? mod.landingPage() : `

${mod.description || "No description available."}

`; - return c.json({ html }); + return c.json({ html, icon: mod.icon || "", name: mod.name || moduleId }); }); // ── x402 test endpoint (no auth, payment-gated only) ── diff --git a/server/shell.ts b/server/shell.ts index 2a4e21c..7022a25 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -173,9 +173,13 @@ export function renderShell(opts: ShellOptions): string { +