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:
parent
7ec1434e64
commit
2cb1ff092b
|
|
@ -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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ──
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 || []) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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) ──
|
||||||
|
|
|
||||||
224
server/shell.ts
224
server/shell.ts
|
|
@ -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">×</button>
|
<button class="rapp-info-panel__close" id="rapp-info-close">×</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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue