diff --git a/modules/rnetwork/components/folk-crm-view.ts b/modules/rnetwork/components/folk-crm-view.ts
index 77dd61e..8082e70 100644
--- a/modules/rnetwork/components/folk-crm-view.ts
+++ b/modules/rnetwork/components/folk-crm-view.ts
@@ -105,11 +105,9 @@ class FolkCrmView extends HTMLElement {
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
- { target: '[data-tab="pipeline"]', title: "Pipeline", message: "Track deals through stages — from incoming leads to closed-won. Drag cards to update their stage.", advanceOnClick: true },
- { target: '[data-tab="contacts"]', title: "Contacts", message: "View and search your contact directory. Click a contact to see their details.", advanceOnClick: true },
+ { target: '.crm-header', title: "CRM Overview", message: "Track deals through stages — from incoming leads to closed-won. Use the tab bar above to switch between Pipeline, Contacts, Companies, Graph, and Delegations.", advanceOnClick: false },
{ target: '#crm-search', title: "Search", message: "Search across contacts and companies by name, email, or city. Results filter in real time.", advanceOnClick: false },
- { target: '[data-tab="graph"]', title: "Relationship Graph", message: "Visualise connections between people and companies as an interactive network graph.", advanceOnClick: true },
- { target: '[data-tab="delegations"]', title: "Delegations", message: "Manage delegative trust — assign voting, moderation, and other authority to community members. View flows as a Sankey diagram.", advanceOnClick: true },
+ { target: '.crm-content', title: "Content Area", message: "Each tab shows a different view: Pipeline cards, contact/company tables, a relationship graph, or delegation management.", advanceOnClick: false },
];
constructor() {
@@ -123,8 +121,27 @@ class FolkCrmView extends HTMLElement {
);
}
+ private _onTabChange = (e: Event) => {
+ const tab = (e as CustomEvent).detail?.tab;
+ if (tab && tab !== this.activeTab) {
+ this.activeTab = tab as Tab;
+ this.searchQuery = "";
+ this.sortColumn = "";
+ this.sortAsc = true;
+ this.render();
+ }
+ };
+
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
+ // Read initial tab from URL
+ const params = new URLSearchParams(window.location.search);
+ const urlTab = params.get("tab");
+ if (urlTab && ["pipeline", "contacts", "companies", "graph", "delegations"].includes(urlTab)) {
+ this.activeTab = urlTab as Tab;
+ }
+ // Listen for server-rendered tab bar changes
+ document.addEventListener("rapp-tab-change", this._onTabChange);
this.render();
this.loadData();
// Auto-start tour on first visit
@@ -133,6 +150,10 @@ class FolkCrmView extends HTMLElement {
}
}
+ disconnectedCallback() {
+ document.removeEventListener("rapp-tab-change", this._onTabChange);
+ }
+
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rnetwork/);
@@ -667,14 +688,6 @@ class FolkCrmView extends HTMLElement {
// ── Main render ──
private render() {
- const tabs: { id: Tab; label: string; count: number }[] = [
- { id: "pipeline", label: "Pipeline", count: this.opportunities.length },
- { id: "contacts", label: "Contacts", count: this.people.length },
- { id: "companies", label: "Companies", count: this.companies.length },
- { id: "graph", label: "Graph", count: 0 },
- { id: "delegations", label: "Delegations", count: 0 },
- ];
-
let content = "";
if (this.loading) {
content = `
Loading CRM data...
`;
@@ -699,17 +712,12 @@ class FolkCrmView extends HTMLElement {
.crm-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.crm-title { font-size: 15px; font-weight: 600; color: var(--rs-text-primary); }
-
- .tabs { display: flex; gap: 2px; background: var(--rs-input-bg); border-radius: 10px; padding: 3px; margin-bottom: 16px; }
- .tab {
- padding: 8px 16px; border-radius: 8px; border: none;
- background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 13px;
- font-weight: 500; transition: all 0.15s;
+ .tour-btn {
+ font-size: 0.78rem; padding: 4px 10px; border-radius: 8px; border: none;
+ background: transparent; color: var(--rs-text-muted); cursor: pointer;
+ font-family: inherit; transition: color 0.15s, background 0.15s;
}
- .tab:hover { color: var(--rs-text-secondary); }
- .tab.active { background: var(--rs-bg-surface); color: var(--rs-text-primary); }
- .tab-count { font-size: 11px; color: var(--rs-text-muted); margin-left: 4px; }
- .tab.active .tab-count { color: var(--rs-primary-hover); }
+ .tour-btn:hover { color: var(--rs-text-primary); background: var(--rs-bg-surface-raised); }
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; }
.search-input {
@@ -820,7 +828,6 @@ class FolkCrmView extends HTMLElement {
}
@media (max-width: 600px) {
.pipeline-grid { grid-template-columns: 1fr; }
- .tabs { flex-wrap: wrap; }
.toolbar { flex-direction: column; align-items: stretch; }
.search-input { width: 100%; }
.data-table td, .data-table th { padding: 8px 10px; font-size: 12px; }
@@ -831,13 +838,7 @@ class FolkCrmView extends HTMLElement {
-
-
- ${tabs.map(t => ``).join("")}
+
${showSearch ? `
@@ -857,17 +858,6 @@ class FolkCrmView extends HTMLElement {
// Tour button
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
- // Tab switching
- this.shadow.querySelectorAll("[data-tab]").forEach(el => {
- el.addEventListener("click", () => {
- this.activeTab = (el as HTMLElement).dataset.tab as Tab;
- this.searchQuery = "";
- this.sortColumn = "";
- this.sortAsc = true;
- this.render();
- });
- });
-
// Search
let searchTimeout: any;
this.shadow.getElementById("crm-search")?.addEventListener("input", (e) => {
diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts
index e5de31a..7cf27c7 100644
--- a/modules/rnetwork/mod.ts
+++ b/modules/rnetwork/mod.ts
@@ -161,7 +161,7 @@ routes.get("/api/users", async (c) => {
// ── API: Trust scores for graph visualization ──
routes.get("/api/trust", async (c) => {
const space = getTrustSpace(c);
- const authority = c.req.query("authority") || "voting";
+ const authority = c.req.query("authority") || "gov-ops";
try {
const res = await fetch(
`${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(space)}&authority=${encodeURIComponent(authority)}`,
@@ -198,7 +198,7 @@ routes.get("/api/graph", async (c) => {
// Check per-space cache (keyed by space + trust params)
const includeTrust = c.req.query("trust") === "true";
- const authority = c.req.query("authority") || "voting";
+ const authority = c.req.query("authority") || "gov-ops";
const cacheKey = includeTrust ? `${dataSpace}:trust:${authority}` : dataSpace;
const cached = graphCaches.get(cacheKey);
if (cached && Date.now() - cached.ts < CACHE_TTL) {
@@ -264,48 +264,73 @@ routes.get("/api/graph", async (c) => {
// Trust data uses per-space scoping (not module's global scoping)
if (includeTrust) {
const trustSpace = c.req.param("space") || "demo";
+ const isAllAuthority = authority === "all";
try {
- const [usersRes, scoresRes, delegRes] = await Promise.all([
+ // For "all" mode: fetch delegations without authority filter, scores for all authorities
+ const delegUrl = new URL(`${ENCRYPTID_URL}/api/delegations/space`);
+ delegUrl.searchParams.set("space", trustSpace);
+ if (!isAllAuthority) delegUrl.searchParams.set("authority", authority);
+
+ const fetches: Promise[] = [
fetch(`${ENCRYPTID_URL}/api/users/directory?space=${encodeURIComponent(trustSpace)}`, { signal: AbortSignal.timeout(5000) }),
- fetch(`${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(trustSpace)}&authority=${encodeURIComponent(authority)}`, { signal: AbortSignal.timeout(5000) }),
- fetch(`${ENCRYPTID_URL}/api/delegations/space?space=${encodeURIComponent(trustSpace)}&authority=${encodeURIComponent(authority)}`, { signal: AbortSignal.timeout(5000) }),
- ]);
+ fetch(delegUrl, { signal: AbortSignal.timeout(5000) }),
+ ];
+ if (isAllAuthority) {
+ for (const a of ["gov-ops", "fin-ops", "dev-ops"]) {
+ fetches.push(fetch(`${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(trustSpace)}&authority=${encodeURIComponent(a)}`, { signal: AbortSignal.timeout(5000) }));
+ }
+ } else {
+ fetches.push(fetch(`${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(trustSpace)}&authority=${encodeURIComponent(authority)}`, { signal: AbortSignal.timeout(5000) }));
+ }
+
+ const responses = await Promise.all(fetches);
+ const [usersRes, delegRes, ...scoreResponses] = responses;
if (usersRes.ok) {
const userData = await usersRes.json() as { users: Array<{ did: string; username: string; displayName: string | null; trustScores: Record }> };
for (const u of userData.users || []) {
if (!nodeIds.has(u.did)) {
- const trustScore = u.trustScores?.[authority] ?? 0;
+ 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, role: "member" },
+ data: { trustScore, authority: isAllAuthority ? "all" : authority, role: "member" },
});
nodeIds.add(u.did);
}
}
}
- if (scoresRes.ok) {
- const scoreData = await scoresRes.json() as { scores: Array<{ did: string; totalScore: number }> };
- const trustMap = new Map();
- for (const s of scoreData.scores || []) {
- trustMap.set(s.did, s.totalScore);
- }
- for (const node of nodes) {
- if (trustMap.has(node.id)) {
- (node.data as any).trustScore = trustMap.get(node.id);
+ // Merge trust scores from all score responses
+ const trustMap = new Map();
+ for (const scoresRes of scoreResponses) {
+ if (scoresRes.ok) {
+ const scoreData = await scoresRes.json() as { scores: Array<{ did: string; totalScore: number }> };
+ for (const s of scoreData.scores || []) {
+ const existing = trustMap.get(s.did) || 0;
+ trustMap.set(s.did, isAllAuthority ? Math.max(existing, s.totalScore) : s.totalScore);
}
}
}
+ for (const node of nodes) {
+ if (trustMap.has(node.id)) {
+ (node.data as any).trustScore = trustMap.get(node.id);
+ }
+ }
- // Add delegation edges
+ // Add delegation edges (with authority tag for per-authority coloring)
if (delegRes.ok) {
const delegData = await delegRes.json() as { delegations: Array<{ from: string; to: string; authority: string; weight: number }> };
for (const d of delegData.delegations || []) {
if (nodeIds.has(d.from) && nodeIds.has(d.to)) {
- edges.push({ source: d.from, target: d.to, type: "delegates_to", weight: d.weight } as any);
+ edges.push({ source: d.from, target: d.to, type: "delegates_to", weight: d.weight, authority: d.authority } as any);
}
}
}
@@ -369,6 +394,13 @@ routes.get("/crm", (c) => {
`,
styles: ``,
+ tabs: [
+ { id: "pipeline", label: "Pipeline" },
+ { id: "contacts", label: "Contacts" },
+ { id: "companies", label: "Companies" },
+ { id: "graph", label: "Graph" },
+ { id: "delegations", label: "Delegations" },
+ ],
}));
});
diff --git a/server/shell.ts b/server/shell.ts
index 19d1511..3c76639 100644
--- a/server/shell.ts
+++ b/server/shell.ts
@@ -63,6 +63,8 @@ export interface ShellOptions {
enabledModules?: string[] | null;
/** Whether this space has client-side encryption enabled */
spaceEncrypted?: boolean;
+ /** Optional tab bar rendered below the subnav. Uses ?tab= query params. */
+ tabs?: Array<{ id: string; label: string; icon?: string }>;
}
export function renderShell(opts: ShellOptions): string {
@@ -128,6 +130,7 @@ export function renderShell(opts: ShellOptions): string {
html.rspace-embedded .rstack-header { display: none !important; }
html.rspace-embedded .rstack-tab-row { display: none !important; }
html.rspace-embedded .rapp-subnav { display: none !important; }
+ html.rspace-embedded .rapp-tabbar { display: none !important; }
html.rspace-embedded #app { padding-top: 0 !important; }
html.rspace-embedded .rspace-iframe-loading,
html.rspace-embedded .rspace-iframe-error { top: 0 !important; }
@@ -139,6 +142,7 @@ export function renderShell(opts: ShellOptions): string {
+
${renderModuleSubNav(moduleId, spaceSlug, visibleModules)}
+ ${opts.tabs ? renderTabBar(opts.tabs) : ''}