= {};
+ for (const c of this.#children) map[c.id] = c.weight;
+ try { localStorage.setItem(key, JSON.stringify(map)); } catch { /* ignore */ }
+ }
+
+ #scheduleEndorse() {
+ if (this.#saveTimer) clearTimeout(this.#saveTimer);
+ this.#saveTimer = setTimeout(() => this.#fireEndorsements(), 1500);
+ }
+
+ async #fireEndorsements() {
+ const space = this.#spaceSlug || (window as any).__rspaceSpace;
+ const token = (window as any).__rspaceAuthToken;
+ if (!space || !token) return;
+
+ for (const child of this.#children) {
+ if (!child.ownerDID) continue;
+ try {
+ await fetch(`/${encodeURIComponent(space)}/rnetwork/api/endorse`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({
+ targetDid: child.ownerDID,
+ authority: 'gov-ops',
+ weight: child.weight / 100,
+ space,
+ }),
+ });
+ } catch { /* silent */ }
+ }
+ }
+
+ // ── Rendering ──
+
+ #render() {
+ if (!this.#contentEl) return;
+
+ // Breadcrumb
+ const bc = this.renderRoot.querySelector('.breadcrumb') as HTMLElement;
+ if (bc) {
+ if (this.#breadcrumb.length === 0) {
+ bc.innerHTML = '';
+ } else {
+ bc.innerHTML = this.#breadcrumb.map((b, i) => {
+ const isLast = i === this.#breadcrumb.length - 1;
+ const sep = i > 0 ? '›' : '';
+ return isLast
+ ? `${sep}${esc(b.label)}`
+ : `${sep}${esc(b.label)}`;
+ }).join('');
+ bc.querySelectorAll('span[data-idx]').forEach((el) => {
+ el.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.#navigateTo(parseInt((el as HTMLElement).dataset.idx!, 10));
+ });
+ });
+ }
+ }
+
+ // Content
+ if (this.#mode === 'h3' && this.#breadcrumb.length === 0) {
+ this.#renderH3Form();
+ } else if (this.#loading) {
+ this.#contentEl.innerHTML = 'Loading...
';
+ } else if (this.#children.length === 0 && this.#breadcrumb.length > 0) {
+ this.#contentEl.innerHTML = 'No children found
';
+ } else if (this.#children.length === 0) {
+ this.#contentEl.innerHTML = 'Enter a space or H3 cell to explore
';
+ } else {
+ this.#renderExplorer();
+ }
+ }
+
+ #renderH3Form() {
+ this.#contentEl!.innerHTML = html`
+
+ `;
+ const input = this.#contentEl!.querySelector('.h3-input') as HTMLInputElement;
+ const btn = this.#contentEl!.querySelector('.go-btn') as HTMLButtonElement;
+ const go = () => {
+ const val = input.value.trim();
+ if (!val) return;
+ try {
+ if (!h3.isValidCell(val)) return;
+ this.#rootId = val;
+ this.#breadcrumb = [{ id: val, label: getResolutionName(h3.getResolution(val)) }];
+ this.#loadH3Children(val);
+ } catch { /* invalid */ }
+ };
+ input.addEventListener('pointerdown', (e) => e.stopPropagation());
+ input.addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter') go(); });
+ btn.addEventListener('click', (e) => { e.stopPropagation(); go(); });
+ }
+
+ #renderExplorer() {
+ const N = this.#children.length;
+ const MAX_VISIBLE = 12;
+ const visible = this.#children.slice(0, MAX_VISIBLE);
+ const overflow = N > MAX_VISIBLE ? N - MAX_VISIBLE : 0;
+
+ // SVG dimensions (use fixed viewBox, scales with container)
+ const W = 400, H = 300;
+ const cx = W / 2, cy = H / 2;
+ let orbit = Math.min(W, H) * 0.35;
+ if (N > 8) orbit *= 0.85;
+
+ // MetatronGrid
+ const gridSvg = metatronGrid(Math.min(W, H));
+
+ // Build node positions
+ const nodesSvg: string[] = [];
+ const linesSvg: string[] = [];
+
+ for (let i = 0; i < visible.length; i++) {
+ const child = visible[i];
+ const angle = -110 + (220 / (visible.length + 1)) * (i + 1);
+ const rad = (angle - 90) * Math.PI / 180;
+ const nx = cx + orbit * Math.cos(rad);
+ const ny = cy + orbit * Math.sin(rad);
+ const r = Math.min(36, Math.max(16, 16 + child.weight * 0.3));
+
+ // Connection line
+ linesSvg.push(
+ ``
+ );
+
+ // Node circle
+ const fill = `rgba(16,185,129,${(0.1 + child.weight * 0.005).toFixed(3)})`;
+ nodesSvg.push(`
+
+
+ ${esc(child.label.length > 14 ? child.label.slice(0, 12) + '…' : child.label)}
+
+ `);
+ }
+
+ // Center node
+ const centerSvg = `
+
+ ${esc(this.#breadcrumb[this.#breadcrumb.length - 1]?.label.slice(0, 8) || '?')}
+ `;
+
+ // Overflow indicator
+ const overflowSvg = overflow > 0
+ ? `+${overflow} more`
+ : '';
+
+ // Slider panel
+ const sliders = visible.map((child, i) => `
+
+
+
+ ${Math.round(child.weight)}
+
+ `).join('');
+
+ const sum = this.#children.reduce((s, c) => s + c.weight, 0);
+
+ this.#contentEl!.innerHTML = html`
+
+
+
+
+ ${sliders}
+
Total: ${Math.round(sum)}%
+
+ `;
+
+ // Wire node clicks
+ this.#contentEl!.querySelectorAll('.node').forEach((g) => {
+ g.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const idx = parseInt((g as HTMLElement).dataset.idx!, 10);
+ if (idx >= 0 && idx < visible.length) this.#drillInto(visible[idx]);
+ });
+ });
+
+ // Wire sliders
+ this.#contentEl!.querySelectorAll('input[type=range]').forEach((el) => {
+ el.addEventListener('pointerdown', (e) => e.stopPropagation());
+ el.addEventListener('input', (e) => {
+ e.stopPropagation();
+ const idx = parseInt((el as HTMLElement).dataset.idx!, 10);
+ const val = parseFloat((el as HTMLInputElement).value);
+ const weights = this.#children.map((c) => c.weight);
+ weights[idx] = val;
+ const normalized = normalize(weights, idx);
+ for (let i = 0; i < this.#children.length; i++) this.#children[i].weight = normalized[i];
+
+ // Update slider UI without full re-render
+ this.#contentEl!.querySelectorAll('.slider-row').forEach((row, ri) => {
+ const inp = row.querySelector('input') as HTMLInputElement;
+ const span = row.querySelector('.val') as HTMLElement;
+ if (inp && span && ri < this.#children.length) {
+ inp.value = String(Math.round(this.#children[ri].weight));
+ span.textContent = String(Math.round(this.#children[ri].weight));
+ }
+ });
+ const sumEl = this.#contentEl!.querySelector('.sum-row');
+ if (sumEl) sumEl.textContent = `Total: ${Math.round(this.#children.reduce((s, c) => s + c.weight, 0))}%`;
+
+ this.#saveWeights();
+ this.#scheduleEndorse();
+ });
+ });
+ }
+
+ // ── Serialization ──
+
+ override toJSON() {
+ return {
+ ...super.toJSON(),
+ type: 'folk-holon-explorer',
+ mode: this.#mode,
+ rootId: this.#rootId,
+ spaceSlug: this.#spaceSlug,
+ breadcrumb: this.#breadcrumb,
+ };
+ }
+
+ static override fromData(data: Record): FolkHolonExplorer {
+ const shape = FolkShape.fromData(data) as FolkHolonExplorer;
+ if (data.mode) shape.setAttribute('mode', data.mode);
+ if (data.rootId) shape.setAttribute('root-id', data.rootId);
+ if (data.spaceSlug) shape.setAttribute('space-slug', data.spaceSlug);
+ return shape;
+ }
+
+ override applyData(data: Record): void {
+ super.applyData(data);
+ const modeChanged = data.mode && data.mode !== this.#mode;
+ const rootChanged = data.rootId && data.rootId !== this.#rootId;
+ const spaceChanged = data.spaceSlug && data.spaceSlug !== this.#spaceSlug;
+
+ if (data.mode) this.#mode = data.mode;
+ if (data.rootId) this.#rootId = data.rootId;
+ if (data.spaceSlug) this.#spaceSlug = data.spaceSlug;
+ if (data.breadcrumb) this.#breadcrumb = data.breadcrumb;
+
+ if (modeChanged || rootChanged || spaceChanged) {
+ if (this.#mode === 'space' && this.#spaceSlug) {
+ if (this.#breadcrumb.length === 0)
+ this.#breadcrumb = [{ id: this.#spaceSlug, label: this.#spaceSlug }];
+ this.#loadSpaceChildren(this.#breadcrumb[this.#breadcrumb.length - 1].id);
+ } else if (this.#mode === 'h3' && this.#rootId) {
+ if (this.#breadcrumb.length === 0)
+ this.#breadcrumb = [{ id: this.#rootId, label: getResolutionName(h3.getResolution(this.#rootId)) }];
+ this.#loadH3Children(this.#breadcrumb[this.#breadcrumb.length - 1].id);
+ }
+ }
+ this.#render();
+ }
+}
+
+function esc(s: string): string {
+ return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+}
diff --git a/lib/index.ts b/lib/index.ts
index a5bd4745..f789d82d 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -109,6 +109,7 @@ export * from "./folk-spider-3d";
// Holon Shapes (H3 geospatial)
export * from "./folk-holon";
export * from "./folk-holon-browser";
+export * from "./folk-holon-explorer";
export * from "./holon-service";
// Nested Space Shape
diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts
index 0bdb82da..e28ab239 100644
--- a/modules/rnetwork/mod.ts
+++ b/modules/rnetwork/mod.ts
@@ -191,6 +191,24 @@ routes.get("/api/delegations", async (c) => {
}
});
+// ── API: Endorse — proxy to EncryptID trust engine ──
+routes.post("/api/endorse", async (c) => {
+ const auth = c.req.header("Authorization");
+ if (!auth) return c.json({ error: "Unauthorized" }, 401);
+ try {
+ const body = await c.req.json();
+ const res = await fetch(`${ENCRYPTID_URL}/api/trust/endorse`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "Authorization": auth },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(5000),
+ });
+ return c.json(await res.json(), res.status as any);
+ } catch {
+ return c.json({ error: "EncryptID unreachable" }, 502);
+ }
+});
+
// ── API: Graph — transform entities to node/edge format ──
routes.get("/api/graph", async (c) => {
const space = c.req.param("space") || "demo";
@@ -770,6 +788,7 @@ export const networkModule: RSpaceModule = {
icon: "🌐",
description: "Community relationship graph visualization with CRM sync",
scoping: { defaultScope: 'global', userConfigurable: false },
+ canvasShapes: ["folk-holon-explorer"],
routes,
landingPage: renderLanding,
standaloneDomain: "rnetwork.online",
diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts
index 8b00c235..b6f08a22 100644
--- a/src/encryptid/server.ts
+++ b/src/encryptid/server.ts
@@ -9174,6 +9174,36 @@ app.get('/api/delegations/space', async (c) => {
});
});
+// POST /api/trust/endorse — log an endorsement trust event
+app.post('/api/trust/endorse', async (c) => {
+ const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
+ if (!claims) return c.json({ error: 'Unauthorized' }, 401);
+
+ const body = await c.req.json().catch(() => null);
+ if (!body) return c.json({ error: 'Invalid JSON body' }, 400);
+
+ const { targetDid, authority, weight, space } = body;
+ if (!targetDid || typeof targetDid !== 'string')
+ return c.json({ error: 'targetDid required (string)' }, 400);
+ if (!space || typeof space !== 'string')
+ return c.json({ error: 'space required (string)' }, 400);
+ const w = typeof weight === 'number' ? Math.max(0, Math.min(1, weight)) : 0.5;
+ const auth = typeof authority === 'string' ? authority : 'gov-ops';
+
+ const event = {
+ id: crypto.randomUUID(),
+ sourceDid: claims.did || `did:key:${claims.sub.slice(0, 32)}`,
+ targetDid,
+ eventType: 'endorsement' as const,
+ authority: auth,
+ weightDelta: w,
+ spaceSlug: space,
+ };
+
+ await logTrustEvent(event);
+ return c.json({ success: true, event });
+});
+
// GET /api/trust/scores — aggregated trust scores for visualization
app.get('/api/trust/scores', async (c) => {
const authority = c.req.query('authority') || 'gov-ops';
diff --git a/website/canvas.html b/website/canvas.html
index 78f06b3e..e43108f5 100644
--- a/website/canvas.html
+++ b/website/canvas.html
@@ -1195,6 +1195,7 @@
#canvas.feed-mode folk-booking,
#canvas.feed-mode folk-holon,
#canvas.feed-mode folk-holon-browser,
+ #canvas.feed-mode folk-holon-explorer,
#canvas.feed-mode folk-feed {
position: relative !important;
transform: none !important;
@@ -1601,6 +1602,7 @@
folk-rapp,
folk-holon,
folk-holon-browser,
+ folk-holon-explorer,
folk-multisig-email {
position: absolute;
}
@@ -2134,6 +2136,7 @@
+
@@ -2497,6 +2500,7 @@
FolkFeed,
FolkHolon,
FolkHolonBrowser,
+ FolkHolonExplorer,
CommunitySync,
PresenceManager,
generatePeerId,
@@ -2780,6 +2784,7 @@
FolkFeed.define();
FolkHolon.define();
FolkHolonBrowser.define();
+ FolkHolonExplorer.define();
// Register all shapes with the shape registry
shapeRegistry.register("folk-shape", FolkShape);
@@ -2841,6 +2846,7 @@
shapeRegistry.register("folk-feed", FolkFeed);
shapeRegistry.register("folk-holon", FolkHolon);
shapeRegistry.register("folk-holon-browser", FolkHolonBrowser);
+ shapeRegistry.register("folk-holon-explorer", FolkHolonExplorer);
// Wire shape→module affiliations from module declarations
for (const mod of window.__rspaceAllModules || []) {
@@ -3212,7 +3218,7 @@
"folk-splat", "folk-blender", "folk-drawfast", "folk-makereal",
"folk-freecad", "folk-kicad",
"folk-rapp",
- "folk-holon", "folk-holon-browser",
+ "folk-holon", "folk-holon-browser", "folk-holon-explorer",
"folk-multisig-email",
"folk-feed"
].join(", ");
@@ -4088,6 +4094,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
"folk-feed": { width: 280, height: 360 },
"folk-holon": { width: 500, height: 400 },
"folk-holon-browser": { width: 400, height: 450 },
+ "folk-holon-explorer": { width: 580, height: 540 },
"folk-transaction-builder": { width: 420, height: 520 },
};
@@ -4809,6 +4816,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
});
document.getElementById("new-holon").addEventListener("click", () => setPendingTool("folk-holon"));
document.getElementById("new-holon-browser").addEventListener("click", () => setPendingTool("folk-holon-browser"));
+ document.getElementById("new-holon-explorer").addEventListener("click", () => setPendingTool("folk-holon-explorer"));
document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));
document.getElementById("new-video-gen").addEventListener("click", () => setPendingTool("folk-video-gen"));
document.getElementById("new-zine-gen").addEventListener("click", () => setPendingTool("folk-zine-gen"));