feat(shell): add consistent rApp tab bar system with URL-backed ?tab= params

Server-rendered tab bar via renderShell tabs option. Tabs use ?tab= query
params with history.replaceState and dispatch rapp-tab-change events.
Migrated rNetwork CRM from internal Shadow DOM tabs to the shared system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 20:50:26 -07:00
parent adb0d173d8
commit f2d575d1a2
3 changed files with 151 additions and 60 deletions

View File

@ -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 = `<div class="loading">Loading CRM data...</div>`;
@ -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 {
<div class="crm-header">
<span class="crm-title">CRM</span>
<a href="https://crm.rspace.online" target="_blank" rel="noopener" style="margin-left:auto;font-size:0.78rem;padding:4px 10px;color:var(--link,#60a5fa);text-decoration:none" title="Open full Twenty CRM">Full CRM &rarr;</a>
<button class="tab" id="btn-tour" style="font-size:0.78rem;padding:4px 10px">Tour</button>
</div>
<div class="tabs">
${tabs.map(t => `<button class="tab ${this.activeTab === t.id ? "active" : ""}" data-tab="${t.id}">
${t.label}${t.count > 0 ? `<span class="tab-count">${t.count}</span>` : ""}
</button>`).join("")}
<button class="tour-btn" id="btn-tour">Tour</button>
</div>
${showSearch ? `<div class="toolbar">
@ -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) => {

View File

@ -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<Response>[] = [
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<string, number> }> };
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<string, number>();
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<string, number>();
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) => {
<script type="module" src="/modules/rnetwork/folk-delegation-manager.js"></script>
<script type="module" src="/modules/rnetwork/folk-trust-sankey.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
tabs: [
{ id: "pipeline", label: "Pipeline" },
{ id: "contacts", label: "Contacts" },
{ id: "companies", label: "Companies" },
{ id: "graph", label: "Graph" },
{ id: "delegations", label: "Delegations" },
],
}));
});

View File

@ -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 {
<style>${ACCESS_GATE_CSS}</style>
<style>${INFO_PANEL_CSS}</style>
<style>${SUBNAV_CSS}</style>
<style>${TABBAR_CSS}</style>
</head>
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}">
<header class="rstack-header">
@ -166,6 +170,7 @@ export function renderShell(opts: ShellOptions): string {
</rstack-tab-bar>
</div>
${renderModuleSubNav(moduleId, spaceSlug, visibleModules)}
${opts.tabs ? renderTabBar(opts.tabs) : ''}
<div id="rapp-info-panel" class="rapp-info-panel" style="display:none">
<div class="rapp-info-panel__header">
<span class="rapp-info-panel__title">About</span>
@ -1212,6 +1217,70 @@ function renderModuleSubNav(moduleId: string, spaceSlug: string, modules: Module
<script>(function(){var ps=document.querySelectorAll('.rapp-subnav__pill'),p=location.pathname.replace(/\\/$/,'');var matched=false;ps.forEach(function(a){var h=a.getAttribute('href');if(h&&h===p){a.classList.add('rapp-subnav__pill--active');matched=true}else if(h&&p.startsWith(h+'/')&&!a.hasAttribute('data-subnav-root')){a.classList.add('rapp-subnav__pill--active');matched=true}});if(!matched){var root=document.querySelector('[data-subnav-root]');if(root)root.classList.add('rapp-subnav__pill--active')}})()</script>`;
}
// ── rApp tab bar (in-page tabs via ?tab= query params) ──
const TABBAR_CSS = `
.rapp-tabbar {
display: flex;
gap: 0.375rem;
padding: 0.25rem 1rem;
overflow-x: auto;
border-bottom: 1px solid var(--rs-border);
background: var(--rs-bg-surface);
scrollbar-width: none;
}
.rapp-tabbar::-webkit-scrollbar { display: none; }
.rapp-tabbar__pill {
padding: 0.25rem 0.625rem;
border-radius: 999px;
font-size: 0.75rem;
white-space: nowrap;
color: var(--rs-text-muted);
text-decoration: none;
border: 1px solid transparent;
cursor: pointer;
background: none;
font-family: inherit;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.rapp-tabbar__pill:hover { color: var(--rs-text-primary); background: var(--rs-bg-surface-raised); }
.rapp-tabbar__pill--active {
color: var(--rs-text-primary);
background: var(--rs-bg-surface-raised);
border-color: var(--rs-border);
font-weight: 500;
}
`;
function renderTabBar(tabs: Array<{ id: string; label: string; icon?: string }>): string {
if (tabs.length === 0) return '';
const pills = tabs.map(t =>
`<button class="rapp-tabbar__pill" data-tab-id="${escapeAttr(t.id)}">${t.icon ? escapeHtml(t.icon) + ' ' : ''}${escapeHtml(t.label)}</button>`
).join('');
return `<nav class="rapp-tabbar" id="rapp-tabbar">${pills}</nav>
<script>(function(){
var pills = document.querySelectorAll('.rapp-tabbar__pill');
var params = new URLSearchParams(location.search);
var active = params.get('tab') || pills[0]?.dataset.tabId || '';
pills.forEach(function(btn) {
if (btn.dataset.tabId === active) btn.classList.add('rapp-tabbar__pill--active');
btn.addEventListener('click', function() {
var id = btn.dataset.tabId;
pills.forEach(function(p) { p.classList.remove('rapp-tabbar__pill--active'); });
btn.classList.add('rapp-tabbar__pill--active');
var u = new URL(location.href);
u.searchParams.set('tab', id);
history.replaceState(null, '', u);
document.dispatchEvent(new CustomEvent('rapp-tab-change', { detail: { tab: id } }));
});
});
// Dispatch initial tab on load so components can read it
document.dispatchEvent(new CustomEvent('rapp-tab-change', { detail: { tab: active } }));
})()</script>`;
}
// ── Module landing page (bare-domain rspace.online/{moduleId}) ──
export interface ModuleLandingOptions {