feat(shell): redesign rApp info popup as centered modal with overlay

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 11:24:41 -07:00
parent 7ec1434e64
commit 2cb1ff092b
8 changed files with 321 additions and 169 deletions

View File

@ -41,12 +41,7 @@ class FolkCartShop extends HTMLElement {
// Guided tour // Guided tour
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ 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. Use the navigation bar above to switch between Carts, Catalog, Group Buys, Orders, and Payments.", 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 },
]; ];
constructor() { constructor() {
@ -473,20 +468,6 @@ class FolkCartShop extends HTMLElement {
} }
private bindEvents() { 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 // Tour button
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour()); this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
@ -1025,7 +1006,7 @@ class FolkCartShop extends HTMLElement {
if (this.groupBuys.length === 0) { if (this.groupBuys.length === 0) {
return `<div class="empty"> return `<div class="empty">
<p>No group buys yet. Start one from any catalog item to unlock volume pricing together.</p> <p>No group buys yet. Start one from any catalog item to unlock volume pricing together.</p>
<button class="btn btn-primary" data-view="catalog">Browse Catalog</button> <a class="btn btn-primary" href="/${this.space}/rcart/catalog">Browse Catalog</a>
</div>`; </div>`;
} }

View File

@ -1634,20 +1634,29 @@ routes.get("/pay/:id", (c) => {
})); }));
}); });
// ── Page route: shop ── // ── Page routes: shop views (subnav tab links) ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo"; function renderShop(space: string, view?: string) {
return c.html(renderShell({ const viewAttr = view ? ` initial-view="${view}"` : "";
return renderShell({
title: `${space} — Shop | rSpace`, title: `${space} — Shop | rSpace`,
moduleId: "rcart", moduleId: "rcart",
spaceSlug: space, spaceSlug: space,
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-cart-shop space="${space}"></folk-cart-shop>`, body: `<folk-cart-shop space="${space}"${viewAttr}></folk-cart-shop>`,
scripts: `<script type="module" src="/modules/rcart/folk-cart-shop.js"></script>`, scripts: `<script type="module" src="/modules/rcart/folk-cart-shop.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`, styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
})); });
}); }
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 ── // ── Seed template data ──

View File

@ -9,7 +9,7 @@
interface GraphNode { interface GraphNode {
id: string; id: string;
name: string; name: string;
type: "person" | "company" | "opportunity" | "rspace_user"; type: "person" | "company" | "opportunity" | "rspace_user" | "space";
workspace: string; workspace: string;
role?: string; role?: string;
location?: string; location?: string;
@ -48,6 +48,7 @@ const NODE_COLORS: Record<string, number> = {
company: 0x22c55e, company: 0x22c55e,
opportunity: 0xf59e0b, opportunity: 0xf59e0b,
rspace_user: 0xa78bfa, rspace_user: 0xa78bfa,
space: 0x64748b,
}; };
const COMPANY_PALETTE = [0x6366f1, 0x22c55e, 0xf59e0b, 0xec4899, 0x14b8a6, 0xf97316, 0x8b5cf6, 0x06b6d4]; const COMPANY_PALETTE = [0x6366f1, 0x22c55e, 0xf59e0b, 0xec4899, 0x14b8a6, 0xf97316, 0x8b5cf6, 0x06b6d4];
@ -58,6 +59,8 @@ const EDGE_STYLES: Record<string, { color: string; width: number; opacity: numbe
point_of_contact: { color: "#c084fc", width: 1.5, opacity: 0.6, dashed: true }, point_of_contact: { color: "#c084fc", width: 1.5, opacity: 0.6, dashed: true },
collaborates: { color: "#f59e0b", width: 1, opacity: 0.4, dashed: true }, collaborates: { color: "#f59e0b", width: 1, opacity: 0.4, dashed: true },
delegates_to: { color: "#a78bfa", width: 2, opacity: 0.6 }, delegates_to: { color: "#a78bfa", width: 2, opacity: 0.6 },
member_of: { color: "#64748b", width: 0.4, opacity: 0.2 },
member_is: { color: "#38bdf8", width: 1.5, opacity: 0.5 },
default: { color: "#666666", width: 0.5, opacity: 0.25 }, default: { color: "#666666", width: 0.5, opacity: 0.25 },
}; };
@ -68,7 +71,7 @@ class FolkGraphViewer extends HTMLElement {
private info: any = null; private info: any = null;
private nodes: GraphNode[] = []; private nodes: GraphNode[] = [];
private edges: GraphEdge[] = []; private edges: GraphEdge[] = [];
private filter: "all" | "person" | "company" | "opportunity" | "rspace_user" = "all"; private filter: "all" | "person" | "company" | "opportunity" | "rspace_user" | "space" = "all";
private searchQuery = ""; private searchQuery = "";
private error = ""; private error = "";
private selectedNode: GraphNode | null = null; private selectedNode: GraphNode | null = null;
@ -211,13 +214,19 @@ class FolkGraphViewer extends HTMLElement {
...this.info, ...this.info,
member_count: this.nodes.filter(n => n.type === "person").length, member_count: this.nodes.filter(n => n.type === "person").length,
company_count: this.nodes.filter(n => n.type === "company").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[] { private getFilteredNodes(): GraphNode[] {
let filtered = this.nodes; let filtered = this.nodes;
if (this.filter !== "all") { 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()) { if (this.searchQuery.trim()) {
const q = this.searchQuery.toLowerCase(); const q = this.searchQuery.toLowerCase();
@ -244,14 +253,17 @@ class FolkGraphViewer extends HTMLElement {
private getNodeRadius(node: GraphNode): number { private getNodeRadius(node: GraphNode): number {
if (node.type === "company") return 22; 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) { if (this.trustMode) {
// Prefer edge-computed delegatedWeight, fall back to trustScore if (node.delegatedWeight != null) return 6 + node.delegatedWeight * 24;
if (node.delegatedWeight != null) { if (node.trustScore != null) return 6 + node.trustScore * 24;
return 6 + node.delegatedWeight * 24;
}
if (node.trustScore != null) {
return 6 + node.trustScore * 24;
}
} }
return 12; return 12;
} }
@ -442,6 +454,8 @@ class FolkGraphViewer extends HTMLElement {
<div class="legend-item"><span class="legend-dot dot-person"></span> People</div> <div class="legend-item"><span class="legend-dot dot-person"></span> People</div>
<div class="legend-item"><span class="legend-dot dot-company"></span> Organizations</div> <div class="legend-item"><span class="legend-dot dot-company"></span> Organizations</div>
<div class="legend-item" id="legend-members" style="display:none"><span class="legend-dot" style="background:#a78bfa"></span> Members</div> <div class="legend-item" id="legend-members" style="display:none"><span class="legend-dot" style="background:#a78bfa"></span> Members</div>
<div class="legend-item" id="legend-space" style="display:none"><span class="legend-dot" style="background:#64748b"></span> Space</div>
<div class="legend-item" id="legend-member-is" style="display:none"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#38bdf8" stroke-width="2"></line></svg> Matched identity</div>
<div class="legend-item"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#888" stroke-width="2"></line></svg> Works at</div> <div class="legend-item"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#888" stroke-width="2"></line></svg> Works at</div>
<div class="legend-item"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#c084fc" stroke-width="2" stroke-dasharray="4 2"></line></svg> Point of contact</div> <div class="legend-item"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#c084fc" stroke-width="2" stroke-dasharray="4 2"></line></svg> Point of contact</div>
<div class="legend-item" id="legend-delegates" style="display:none"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#a78bfa" stroke-width="2"></line></svg> Delegates to</div> <div class="legend-item" id="legend-delegates" style="display:none"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#a78bfa" stroke-width="2"></line></svg> Delegates to</div>
@ -610,13 +624,39 @@ class FolkGraphViewer extends HTMLElement {
this.updateDetailPanel(); this.updateDetailPanel();
this.updateGraphData(); // refresh highlight this.updateGraphData(); // refresh highlight
}) })
.d3AlphaDecay(0.03) .d3AlphaDecay(0.02)
.d3VelocityDecay(0.4) .d3VelocityDecay(0.3)
.warmupTicks(80) .warmupTicks(120)
.cooldownTicks(200); .cooldownTicks(300);
this.graph = graph; 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 // Remap controls: LEFT=PAN, RIGHT=ROTATE, MIDDLE=DOLLY
// THREE.MOUSE: ROTATE=0, DOLLY=1, PAN=2 // THREE.MOUSE: ROTATE=0, DOLLY=1, PAN=2
const controls = graph.controls(); const controls = graph.controls();
@ -715,7 +755,7 @@ class FolkGraphViewer extends HTMLElement {
} }
// Trust badge sprite // Trust badge sprite
if (node.type !== "company") { if (node.type !== "company" && node.type !== "space") {
const trust = this.getTrustScore(node.id); const trust = this.getTrustScore(node.id);
if (trust >= 0) { if (trust >= 0) {
const badge = this.createBadgeSprite(THREE, String(trust)); const badge = this.createBadgeSprite(THREE, String(trust));
@ -810,11 +850,16 @@ class FolkGraphViewer extends HTMLElement {
links: filteredEdges, 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 membersLegend = this.shadow.getElementById("legend-members");
const delegatesLegend = this.shadow.getElementById("legend-delegates"); const delegatesLegend = this.shadow.getElementById("legend-delegates");
const authorityColors = this.shadow.getElementById("legend-authority-colors"); 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 (delegatesLegend) delegatesLegend.style.display = (this.trustMode && this.authority !== "all") ? "" : "none";
if (authorityColors) authorityColors.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"); const bar = this.shadow.getElementById("stats-bar");
if (!bar || !this.info) return; if (!bar || !this.info) return;
const crossOrg = this.edges.filter(e => e.type === "point_of_contact").length; const crossOrg = this.edges.filter(e => e.type === "point_of_contact").length;
const memberCount = this.info.rspace_member_count || 0;
bar.innerHTML = ` bar.innerHTML = `
<div class="stat"><div class="stat-value">${this.info.member_count || 0}</div><div class="stat-label">People</div></div> <div class="stat"><div class="stat-value">${this.info.member_count || 0}</div><div class="stat-label">People</div></div>
<div class="stat"><div class="stat-value">${this.info.company_count || 0}</div><div class="stat-label">Organizations</div></div> <div class="stat"><div class="stat-value">${this.info.company_count || 0}</div><div class="stat-label">Organizations</div></div>
${memberCount > 0 ? `<div class="stat"><div class="stat-value">${memberCount}</div><div class="stat-label">Members</div></div>` : ""}
<div class="stat"><div class="stat-value">${crossOrg}</div><div class="stat-label">Cross-org Links</div></div> <div class="stat"><div class="stat-value">${crossOrg}</div><div class="stat-label">Cross-org Links</div></div>
`; `;
} }
@ -859,15 +906,15 @@ class FolkGraphViewer extends HTMLElement {
const n = this.selectedNode; const n = this.selectedNode;
const connected = this.getConnectedNodes(n.id); 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.classList.add("visible");
panel.innerHTML = ` panel.innerHTML = `
<div class="detail-header"> <div class="detail-header">
<span class="detail-icon">${n.type === "company" ? "\u{1F3E2}" : "\u{1F464}"}</span> <span class="detail-icon">${n.type === "company" ? "\u{1F3E2}" : n.type === "space" ? "\u{1F310}" : "\u{1F464}"}</span>
<div class="detail-info"> <div class="detail-info">
<div class="detail-name">${this.esc(n.name)}</div> <div class="detail-name">${this.esc(n.name)}</div>
<div class="detail-type">${this.esc(n.type === "company" ? "Organization" : n.role || "Person")}${n.location ? ` \u00b7 ${this.esc(n.location)}` : ""}</div> <div class="detail-type">${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)}` : ""}</div>
</div> </div>
<button class="detail-close" id="close-detail">\u2715</button> <button class="detail-close" id="close-detail">\u2715</button>
</div> </div>

View File

@ -200,7 +200,8 @@ routes.get("/api/graph", async (c) => {
// Check per-space cache (keyed by space + trust params) // Check per-space cache (keyed by space + trust params)
const includeTrust = c.req.query("trust") === "true"; const includeTrust = c.req.query("trust") === "true";
const authority = c.req.query("authority") || "gov-ops"; 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); const cached = graphCaches.get(cacheKey);
if (cached && Date.now() - cached.ts < CACHE_TTL) { if (cached && Date.now() - cached.ts < CACHE_TTL) {
c.header("Cache-Control", "public, max-age=60"); 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 // Always fetch EncryptID members for space context
// Trust data uses per-space scoping (not module's global scoping) // 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<string, number> };
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) { if (includeTrust) {
const trustSpace = c.req.param("space") || "demo";
const isAllAuthority = authority === "all";
try { try {
// For "all" mode: fetch delegations without authority filter, scores for all authorities
const delegUrl = new URL(`${ENCRYPTID_URL}/api/delegations/space`); const delegUrl = new URL(`${ENCRYPTID_URL}/api/delegations/space`);
delegUrl.searchParams.set("space", trustSpace); delegUrl.searchParams.set("space", trustSpace);
if (!isAllAuthority) delegUrl.searchParams.set("authority", authority); if (!isAllAuthority) delegUrl.searchParams.set("authority", authority);
const fetches: Promise<Response>[] = [ const fetches: Promise<Response>[] = [
fetch(`${ENCRYPTID_URL}/api/users/directory?space=${encodeURIComponent(trustSpace)}`, { signal: AbortSignal.timeout(5000) }),
fetch(delegUrl, { signal: AbortSignal.timeout(5000) }), fetch(delegUrl, { signal: AbortSignal.timeout(5000) }),
]; ];
if (isAllAuthority) { if (isAllAuthority) {
@ -285,31 +347,9 @@ routes.get("/api/graph", async (c) => {
} }
const responses = await Promise.all(fetches); const responses = await Promise.all(fetches);
const [usersRes, delegRes, ...scoreResponses] = responses; const [delegRes, ...scoreResponses] = responses;
if (usersRes.ok) { // Merge trust scores
const userData = await usersRes.json() as { users: Array<{ did: string; username: string; displayName: string | null; trustScores: Record<string, number> }> };
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
const trustMap = new Map<string, number>(); const trustMap = new Map<string, number>();
for (const scoresRes of scoreResponses) { for (const scoresRes of scoreResponses) {
if (scoresRes.ok) { 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) { if (delegRes.ok) {
const delegData = await delegRes.json() as { delegations: Array<{ from: string; to: string; authority: string; weight: number }> }; const delegData = await delegRes.json() as { delegations: Array<{ from: string; to: string; authority: string; weight: number }> };
for (const d of delegData.delegations || []) { for (const d of delegData.delegations || []) {

View File

@ -142,6 +142,12 @@ class FolkWalletViewer extends HTMLElement {
} }
connectedCallback() { 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") || ""; const space = this.getAttribute("space") || "";
if (space === "demo") { if (space === "demo") {
this.loadDemoData(); this.loadDemoData();

View File

@ -829,20 +829,27 @@ routes.post("/api/crdt-tokens/:tokenId/mint", async (c) => {
}); });
// ── Page route ── // ── Page route ──
routes.get("/", (c) => { // ── Page routes: subnav tab links ──
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; function renderWallet(spaceSlug: string, initialView?: string) {
return c.html(renderShell({ const viewAttr = initialView ? ` initial-view="${initialView}"` : "";
return renderShell({
title: `${spaceSlug} — Wallet | rSpace`, title: `${spaceSlug} — Wallet | rSpace`,
moduleId: "rwallet", moduleId: "rwallet",
spaceSlug, spaceSlug,
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-wallet-viewer></folk-wallet-viewer>`, body: `<folk-wallet-viewer${viewAttr}></folk-wallet-viewer>`,
scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=6"></script>`, scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=6"></script>`,
styles: `<link rel="stylesheet" href="/modules/rwallet/wallet.css">`, styles: `<link rel="stylesheet" href="/modules/rwallet/wallet.css">`,
})); });
}); }
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 = { export const walletModule: RSpaceModule = {
id: "rwallet", id: "rwallet",

View File

@ -589,7 +589,7 @@ app.get("/api/modules/:moduleId/landing", (c) => {
const mod = getModule(moduleId); const mod = getModule(moduleId);
if (!mod) return c.json({ error: "Module not found" }, 404); if (!mod) return c.json({ error: "Module not found" }, 404);
const html = mod.landingPage ? mod.landingPage() : `<p>${mod.description || "No description available."}</p>`; const html = mod.landingPage ? mod.landingPage() : `<p>${mod.description || "No description available."}</p>`;
return c.json({ html }); return c.json({ html, icon: mod.icon || "", name: mod.name || moduleId });
}); });
// ── x402 test endpoint (no auth, payment-gated only) ── // ── x402 test endpoint (no auth, payment-gated only) ──

View File

@ -173,9 +173,13 @@ export function renderShell(opts: ShellOptions): string {
<button class="rapp-info-btn" id="rapp-info-btn" title="About this rApp"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></button> <button class="rapp-info-btn" id="rapp-info-btn" title="About this rApp"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></button>
</rstack-tab-bar> </rstack-tab-bar>
</div> </div>
<div id="rapp-info-overlay" class="rapp-info-overlay" style="display:none"></div>
<div id="rapp-info-panel" class="rapp-info-panel" style="display:none"> <div id="rapp-info-panel" class="rapp-info-panel" style="display:none">
<div class="rapp-info-panel__header"> <div class="rapp-info-panel__header">
<span class="rapp-info-panel__title">About</span> <div class="rapp-info-panel__header-left">
<span class="rapp-info-panel__icon" id="rapp-info-icon"></span>
<span class="rapp-info-panel__title" id="rapp-info-title">About this rApp</span>
</div>
<button class="rapp-info-panel__close" id="rapp-info-close">&times;</button> <button class="rapp-info-panel__close" id="rapp-info-close">&times;</button>
</div> </div>
<div class="rapp-info-panel__body" id="rapp-info-body"></div> <div class="rapp-info-panel__body" id="rapp-info-body"></div>
@ -235,6 +239,9 @@ export function renderShell(opts: ShellOptions): string {
const infoPanel = document.getElementById('rapp-info-panel'); const infoPanel = document.getElementById('rapp-info-panel');
const infoBody = document.getElementById('rapp-info-body'); const infoBody = document.getElementById('rapp-info-body');
const infoClose = document.getElementById('rapp-info-close'); const infoClose = document.getElementById('rapp-info-close');
const infoOverlay = document.getElementById('rapp-info-overlay');
const infoIcon = document.getElementById('rapp-info-icon');
const infoTitle = document.getElementById('rapp-info-title');
if (!infoBtn || !infoPanel || !infoBody) return; if (!infoBtn || !infoPanel || !infoBody) return;
let infoPanelModuleId = ''; let infoPanelModuleId = '';
@ -242,16 +249,20 @@ export function renderShell(opts: ShellOptions): string {
function showInfoPanel(moduleId) { function showInfoPanel(moduleId) {
infoPanelModuleId = moduleId; infoPanelModuleId = moduleId;
infoPanel.style.display = ''; infoPanel.style.display = '';
if (infoOverlay) infoOverlay.style.display = '';
infoBtn.classList.add('rapp-info-btn--active'); infoBtn.classList.add('rapp-info-btn--active');
// Lazy-load content // Lazy-load content
if (infoPanel.dataset.loadedModule !== moduleId) { if (infoPanel.dataset.loadedModule !== moduleId) {
infoBody.innerHTML = '<div class="rapp-info-panel__loading">Loading…</div>'; infoBody.innerHTML = '<div class="rapp-info-panel__loading"><div class="rapp-info-panel__spinner"></div>Loading…</div>';
fetch('/api/modules/' + encodeURIComponent(moduleId) + '/landing') fetch('/api/modules/' + encodeURIComponent(moduleId) + '/landing')
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (infoPanelModuleId !== moduleId) return; // stale if (infoPanelModuleId !== moduleId) return; // stale
infoBody.innerHTML = data.html || '<p>No info available.</p>'; infoBody.innerHTML = data.html || '<p>No info available.</p>';
infoPanel.dataset.loadedModule = moduleId; infoPanel.dataset.loadedModule = moduleId;
// Update header with module icon + name
if (infoIcon && data.icon) infoIcon.textContent = data.icon;
if (infoTitle && data.name) infoTitle.textContent = data.name;
}) })
.catch(() => { infoBody.innerHTML = '<p>Failed to load info.</p>'; }); .catch(() => { infoBody.innerHTML = '<p>Failed to load info.</p>'; });
} }
@ -259,9 +270,16 @@ export function renderShell(opts: ShellOptions): string {
function hideInfoPanel() { function hideInfoPanel() {
infoPanel.style.display = 'none'; infoPanel.style.display = 'none';
if (infoOverlay) infoOverlay.style.display = 'none';
infoBtn.classList.remove('rapp-info-btn--active'); infoBtn.classList.remove('rapp-info-btn--active');
} }
// Click overlay to dismiss
if (infoOverlay) infoOverlay.addEventListener('click', hideInfoPanel);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && infoPanel.style.display !== 'none') hideInfoPanel();
});
infoBtn.addEventListener('click', () => { infoBtn.addEventListener('click', () => {
if (infoPanel.style.display !== 'none') { hideInfoPanel(); return; } if (infoPanel.style.display !== 'none') { hideInfoPanel(); return; }
// Resolve the currently active module from the tab bar // Resolve the currently active module from the tab bar
@ -1096,6 +1114,7 @@ const WELCOME_CSS = `
`; `;
const INFO_PANEL_CSS = ` const INFO_PANEL_CSS = `
/* ── Info button in tab bar ── */
.rapp-info-btn { .rapp-info-btn {
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; padding: 0; margin-right: 2px; width: 28px; height: 28px; padding: 0; margin-right: 2px;
@ -1105,39 +1124,69 @@ const INFO_PANEL_CSS = `
} }
.rapp-info-btn:hover { color: var(--rs-text-primary); background: var(--rs-bg-hover); border-color: var(--rs-border); } .rapp-info-btn:hover { color: var(--rs-text-primary); background: var(--rs-bg-hover); border-color: var(--rs-border); }
.rapp-info-btn--active { color: var(--rs-primary); background: var(--rs-bg-hover); border-color: var(--rs-primary); } .rapp-info-btn--active { color: var(--rs-primary); background: var(--rs-bg-hover); border-color: var(--rs-primary); }
/* ── Backdrop overlay ── */
.rapp-info-overlay {
position: fixed; inset: 0; z-index: 10000;
background: rgba(0,0,0,0.45);
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: rapp-overlay-in 0.2s ease-out;
}
@keyframes rapp-overlay-in { from { opacity: 0; } to { opacity: 1; } }
/* ── Panel — centered modal ── */
.rapp-info-panel { .rapp-info-panel {
position: fixed; top: 80px; right: 16px; z-index: 9000; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: min(480px, calc(100vw - 32px)); max-height: calc(100vh - 100px); z-index: 10001;
background: var(--rs-bg-surface); border: 1px solid rgba(20,184,166,0.2); width: min(520px, calc(100vw - 32px)); max-height: calc(100vh - 64px);
border-radius: 14px; box-shadow: 0 12px 48px rgba(0,0,0,0.5), 0 0 0 1px rgba(20,184,166,0.08); background: var(--rs-bg-surface);
border: 1px solid rgba(20,184,166,0.25);
border-radius: 16px;
box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(20,184,166,0.1), 0 0 60px rgba(20,184,166,0.06);
display: flex; flex-direction: column; overflow: hidden; display: flex; flex-direction: column; overflow: hidden;
animation: rapp-info-in 0.25s ease-out; animation: rapp-info-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
} }
@keyframes rapp-info-in { @keyframes rapp-info-in {
from { opacity: 0; transform: translateY(-10px) scale(0.96); } from { opacity: 0; transform: translate(-50%, -48%) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
} }
/* ── Header with icon + name ── */
.rapp-info-panel__header { .rapp-info-panel__header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; padding: 16px 20px;
background: linear-gradient(135deg, rgba(20,184,166,0.1), rgba(79,70,229,0.08)); background: linear-gradient(135deg, rgba(20,184,166,0.12), rgba(79,70,229,0.08));
border-bottom: 1px solid rgba(20,184,166,0.15); border-bottom: 1px solid rgba(20,184,166,0.18);
}
.rapp-info-panel__header-left {
display: flex; align-items: center; gap: 10px;
}
.rapp-info-panel__icon {
font-size: 1.5rem; line-height: 1;
} }
.rapp-info-panel__title { .rapp-info-panel__title {
font-size: 0.85rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; font-size: 1.05rem; font-weight: 700; letter-spacing: 0.01em;
background: linear-gradient(135deg, #14b8a6, #22d3ee); background: linear-gradient(135deg, #14b8a6, #22d3ee);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; -webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
} }
.rapp-info-panel__close { .rapp-info-panel__close {
background: none; border: none; color: var(--rs-text-muted); display: flex; align-items: center; justify-content: center;
font-size: 1.3rem; cursor: pointer; padding: 2px 6px; border-radius: 6px; width: 32px; height: 32px;
line-height: 1; transition: color 0.15s, background 0.15s; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08);
color: var(--rs-text-muted); font-size: 1.2rem; cursor: pointer;
border-radius: 8px; line-height: 1;
transition: color 0.15s, background 0.15s, border-color 0.15s;
} }
.rapp-info-panel__close:hover { color: var(--rs-text-primary); background: rgba(255,255,255,0.08); } .rapp-info-panel__close:hover {
color: var(--rs-text-primary); background: rgba(255,255,255,0.1);
border-color: rgba(255,255,255,0.15);
}
/* ── Body ── */
.rapp-info-panel__body { .rapp-info-panel__body {
padding: 0; overflow-y: auto; flex: 1; padding: 0; overflow-y: auto; flex: 1;
color: var(--rs-text-secondary); font-size: 0.88rem; line-height: 1.6; color: var(--rs-text-secondary); font-size: 0.92rem; line-height: 1.65;
} }
.rapp-info-panel__body h1, .rapp-info-panel__body h2, .rapp-info-panel__body h3 { .rapp-info-panel__body h1, .rapp-info-panel__body h2, .rapp-info-panel__body h3 {
color: var(--rs-text-primary); margin: 0 0 8px; color: var(--rs-text-primary); margin: 0 0 8px;
@ -1147,86 +1196,94 @@ const INFO_PANEL_CSS = `
.rapp-info-panel__body h3 { font-size: 0.95rem; } .rapp-info-panel__body h3 { font-size: 0.95rem; }
.rapp-info-panel__body p { margin: 0 0 10px; } .rapp-info-panel__body p { margin: 0 0 10px; }
.rapp-info-panel__body a { color: var(--rs-primary); } .rapp-info-panel__body a { color: var(--rs-primary); }
/* ── Loading state ── */
.rapp-info-panel__loading { .rapp-info-panel__loading {
display: flex; align-items: center; justify-content: center; display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 32px; color: var(--rs-text-muted); gap: 12px; padding: 48px 32px; color: var(--rs-text-muted);
} }
.rapp-info-panel__spinner {
width: 24px; height: 24px;
border: 2px solid rgba(20,184,166,0.2); border-top-color: #14b8a6;
border-radius: 50%; animation: rapp-spin 0.7s linear infinite;
}
@keyframes rapp-spin { to { transform: rotate(360deg); } }
/* ── Panel-scoped overrides for rich landing content ── */ /* ── Panel-scoped overrides for rich landing content ── */
.rapp-info-panel__body .rl-hero { .rapp-info-panel__body .rl-hero { padding: 1.75rem 1.5rem 1.25rem; text-align: center; }
padding: 1.75rem 1.25rem 1.25rem; text-align: center; .rapp-info-panel__body .rl-hero .rl-heading { font-size: 1.5rem !important; }
} .rapp-info-panel__body .rl-heading { font-size: 1.2rem; margin-bottom: 0.5rem; }
.rapp-info-panel__body .rl-hero .rl-heading { .rapp-info-panel__body .rl-tagline { font-size: 0.65rem; padding: 0.3rem 0.85rem; margin-bottom: 1rem; }
font-size: 1.5rem !important; .rapp-info-panel__body .rl-subtitle { font-size: 1rem !important; margin-bottom: 0.75rem; }
} .rapp-info-panel__body .rl-subtext { font-size: 0.9rem !important; margin-bottom: 1.25rem; line-height: 1.7; }
.rapp-info-panel__body .rl-heading {
font-size: 1.2rem; margin-bottom: 0.5rem;
}
.rapp-info-panel__body .rl-tagline {
font-size: 0.6rem; padding: 0.25rem 0.75rem; margin-bottom: 1rem;
}
.rapp-info-panel__body .rl-subtitle {
font-size: 1rem !important; margin-bottom: 0.75rem;
}
.rapp-info-panel__body .rl-subtext {
font-size: 0.88rem !important; margin-bottom: 1.25rem;
}
.rapp-info-panel__body .rl-hero .rl-subtext { font-size: 0.92rem !important; } .rapp-info-panel__body .rl-hero .rl-subtext { font-size: 0.92rem !important; }
.rapp-info-panel__body .rl-section { .rapp-info-panel__body .rl-section { padding: 1.5rem 1.25rem; border-top: 1px solid var(--rs-border-subtle); }
padding: 1.5rem 1.25rem; border-top: 1px solid var(--rs-border-subtle); .rapp-info-panel__body .rl-section--alt { background: rgba(20,184,166,0.03); }
}
.rapp-info-panel__body .rl-section--alt {
background: rgba(20,184,166,0.03);
}
.rapp-info-panel__body .rl-container { max-width: 100%; } .rapp-info-panel__body .rl-container { max-width: 100%; }
/* Grids: max 2 columns in panel */ /* Grids: max 2 columns in panel */
.rapp-info-panel__body .rl-grid-2, .rapp-info-panel__body .rl-grid-2,
.rapp-info-panel__body .rl-grid-3, .rapp-info-panel__body .rl-grid-3,
.rapp-info-panel__body .rl-grid-4 { .rapp-info-panel__body .rl-grid-4 { grid-template-columns: 1fr 1fr !important; gap: 0.75rem; }
grid-template-columns: 1fr 1fr !important; gap: 0.75rem;
}
/* Cards */ /* Cards */
.rapp-info-panel__body .rl-card { .rapp-info-panel__body .rl-card { padding: 1.15rem; border-radius: 0.75rem; }
padding: 1.15rem; border-radius: 0.75rem; .rapp-info-panel__body .rl-card h3 { font-size: 0.88rem; margin-bottom: 0.35rem; }
} .rapp-info-panel__body .rl-card p { font-size: 0.82rem; line-height: 1.55; margin-bottom: 0; }
.rapp-info-panel__body .rl-card h3 { font-size: 0.85rem; margin-bottom: 0.35rem; }
.rapp-info-panel__body .rl-card p { font-size: 0.8rem; line-height: 1.5; margin-bottom: 0; }
/* Icon boxes */ /* Icon boxes */
.rapp-info-panel__body .rl-icon-box { .rapp-info-panel__body .rl-icon-box { width: 2.5rem; height: 2.5rem; font-size: 1.25rem; border-radius: 0.6rem; margin-bottom: 0.65rem; }
width: 2.25rem; height: 2.25rem; font-size: 1.15rem;
border-radius: 0.5rem; margin-bottom: 0.65rem;
}
.rapp-info-panel__body .rl-card--center .rl-icon-box { margin: 0 auto 0.65rem; } .rapp-info-panel__body .rl-card--center .rl-icon-box { margin: 0 auto 0.65rem; }
/* Steps */ /* Steps */
.rapp-info-panel__body .rl-step__num { .rapp-info-panel__body .rl-step__num { width: 2.25rem; height: 2.25rem; font-size: 0.75rem; margin-bottom: 0.5rem; }
width: 2rem; height: 2rem; font-size: 0.7rem; margin-bottom: 0.5rem; .rapp-info-panel__body .rl-step h3 { font-size: 0.88rem; }
} .rapp-info-panel__body .rl-step p { font-size: 0.82rem; }
.rapp-info-panel__body .rl-step h3 { font-size: 0.85rem; }
.rapp-info-panel__body .rl-step p { font-size: 0.78rem; }
/* CTAs */ /* CTAs — bigger, bolder buttons */
.rapp-info-panel__body .rl-cta-row { margin-top: 1.25rem; gap: 0.5rem; } .rapp-info-panel__body .rl-cta-row { margin-top: 1.5rem; gap: 0.625rem; display: flex; flex-wrap: wrap; justify-content: center; }
.rapp-info-panel__body .rl-cta-primary, .rapp-info-panel__body .rl-cta-primary {
padding: 0.75rem 1.5rem; font-size: 0.92rem; font-weight: 600;
border-radius: 10px; text-decoration: none;
background: var(--rs-gradient-cta, linear-gradient(135deg, #14b8a6, #06b6d4));
color: #fff; box-shadow: 0 4px 16px rgba(20,184,166,0.3);
transition: transform 0.15s, box-shadow 0.15s;
}
.rapp-info-panel__body .rl-cta-primary:hover { transform: translateY(-1px); box-shadow: 0 6px 24px rgba(20,184,166,0.4); }
.rapp-info-panel__body .rl-cta-secondary { .rapp-info-panel__body .rl-cta-secondary {
padding: 0.6rem 1.25rem; font-size: 0.85rem; border-radius: 0.5rem; padding: 0.75rem 1.5rem; font-size: 0.92rem; font-weight: 600;
border-radius: 10px; text-decoration: none;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
color: var(--rs-text-secondary); transition: transform 0.15s, border-color 0.15s, color 0.15s, background 0.15s;
}
.rapp-info-panel__body .rl-cta-secondary:hover { transform: translateY(-1px); border-color: rgba(20,184,166,0.4); color: var(--rs-text-primary); background: rgba(20,184,166,0.08); }
/* Tour / guide links — promote to prominent buttons */
.rapp-info-panel__body a[onclick*="startTour"],
.rapp-info-panel__body a[href*="tour"] {
display: inline-flex; align-items: center; gap: 6px;
padding: 0.65rem 1.35rem; margin-top: 0.75rem;
font-size: 0.92rem; font-weight: 600; text-decoration: none;
background: linear-gradient(135deg, rgba(79,70,229,0.15), rgba(20,184,166,0.1));
border: 1px solid rgba(79,70,229,0.25); border-radius: 10px; color: #a78bfa;
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.15s;
}
.rapp-info-panel__body a[onclick*="startTour"]:hover,
.rapp-info-panel__body a[href*="tour"]:hover {
background: linear-gradient(135deg, rgba(79,70,229,0.25), rgba(20,184,166,0.15));
border-color: rgba(79,70,229,0.4); color: #c4b5fd; transform: translateY(-1px);
} }
/* Badges */ /* Badges */
.rapp-info-panel__body .rl-badge { font-size: 0.6rem; padding: 0.12rem 0.45rem; } .rapp-info-panel__body .rl-badge { font-size: 0.65rem; padding: 0.15rem 0.5rem; }
/* Integration cards */ /* Integration cards */
.rapp-info-panel__body .rl-integration { .rapp-info-panel__body .rl-integration { padding: 1rem; border-radius: 0.75rem; gap: 0.75rem; }
padding: 1rem; border-radius: 0.75rem; gap: 0.75rem; .rapp-info-panel__body .rl-integration h3 { font-size: 0.88rem; }
} .rapp-info-panel__body .rl-integration p { font-size: 0.82rem; }
.rapp-info-panel__body .rl-integration h3 { font-size: 0.85rem; }
.rapp-info-panel__body .rl-integration p { font-size: 0.8rem; }
/* Check list */ /* Check list */
.rapp-info-panel__body .rl-check-list li { font-size: 0.8rem; padding: 0.25rem 0; } .rapp-info-panel__body .rl-check-list li { font-size: 0.82rem; padding: 0.3rem 0; }
/* Divider */ /* Divider */
.rapp-info-panel__body .rl-divider { margin: 1rem 0; } .rapp-info-panel__body .rl-divider { margin: 1rem 0; }
@ -1234,23 +1291,27 @@ const INFO_PANEL_CSS = `
/* Back link */ /* Back link */
.rapp-info-panel__body .rl-back { padding: 1.25rem 0 1.5rem; } .rapp-info-panel__body .rl-back { padding: 1.25rem 0 1.5rem; }
.rapp-info-panel__body .rl-back a { font-size: 0.8rem; } .rapp-info-panel__body .rl-back a { font-size: 0.82rem; }
/* Scrollbar styling */ /* Scrollbar styling */
.rapp-info-panel__body::-webkit-scrollbar { width: 5px; } .rapp-info-panel__body::-webkit-scrollbar { width: 5px; }
.rapp-info-panel__body::-webkit-scrollbar-track { background: transparent; } .rapp-info-panel__body::-webkit-scrollbar-track { background: transparent; }
.rapp-info-panel__body::-webkit-scrollbar-thumb { .rapp-info-panel__body::-webkit-scrollbar-thumb { background: rgba(20,184,166,0.2); border-radius: 9999px; }
background: rgba(20,184,166,0.2); border-radius: 9999px;
}
.rapp-info-panel__body::-webkit-scrollbar-thumb:hover { background: rgba(20,184,166,0.35); } .rapp-info-panel__body::-webkit-scrollbar-thumb:hover { background: rgba(20,184,166,0.35); }
@media (max-width: 600px) { @media (max-width: 600px) {
.rapp-info-panel { right: 8px; left: 8px; width: auto; top: 70px; } .rapp-info-panel {
top: auto; left: 8px; right: 8px; bottom: 8px;
transform: none; width: auto; max-height: 85vh;
animation: rapp-info-in-mobile 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes rapp-info-in-mobile {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.rapp-info-panel__body .rl-grid-2, .rapp-info-panel__body .rl-grid-2,
.rapp-info-panel__body .rl-grid-3, .rapp-info-panel__body .rl-grid-3,
.rapp-info-panel__body .rl-grid-4 { .rapp-info-panel__body .rl-grid-4 { grid-template-columns: 1fr !important; }
grid-template-columns: 1fr !important;
}
} }
`; `;
@ -1260,7 +1321,7 @@ const SUBNAV_CSS = `
.rapp-subnav { .rapp-subnav {
display: flex; display: flex;
gap: 0.375rem; gap: 0.375rem;
padding: 0.375rem 1rem; padding: 0.5rem 1rem;
overflow-x: auto; overflow-x: auto;
border-bottom: 1px solid var(--rs-border); border-bottom: 1px solid var(--rs-border);
background: var(--rs-bg-surface); background: var(--rs-bg-surface);
@ -1268,6 +1329,7 @@ const SUBNAV_CSS = `
position: sticky; position: sticky;
top: 92px; top: 92px;
z-index: 100; z-index: 100;
margin-bottom: 4px;
} }
.rapp-subnav::-webkit-scrollbar { display: none; } .rapp-subnav::-webkit-scrollbar { display: none; }
@media (max-width: 640px) { @media (max-width: 640px) {