feat(rnetwork): enhanced trust flow viz + rename authorities to gov/fin/dev-ops

- Compute delegatedWeight per node from delegation edges (both directions)
- Animated directional particles on delegation edges (count/size ~ weight)
- Wider delegation edges (1+weight*8) with 0.15 curvature
- "All" authority overlay mode with per-authority colored edges
- Rename 5 authorities (voting/moderation/curation/treasury/membership)
  to 3 verticals: gov-ops, fin-ops, dev-ops
- DB migration: update CHECK constraint + migrate existing data
- Update all frontend components + backend defaults

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 20:51:30 -07:00
parent f2d575d1a2
commit 4c44eb9941
7 changed files with 113 additions and 25 deletions

View File

@ -1,7 +1,7 @@
/**
* <folk-delegation-manager> per-vertical delegation management UI.
*
* Shows bars for each authority vertical (voting, moderation, curation, treasury, membership)
* Shows bars for each authority vertical (gov-ops, fin-ops, dev-ops)
* with percentage allocated and delegate avatars. Supports create, edit, revoke.
* Weight sum validated client-side before submission.
*/
@ -30,13 +30,11 @@ interface SpaceUser {
role: string;
}
const AUTHORITIES = ["voting", "moderation", "curation", "treasury", "membership"] as const;
const AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const;
const AUTHORITY_ICONS: Record<string, string> = {
voting: "\u{1F5F3}\uFE0F",
moderation: "\u{1F6E1}\uFE0F",
curation: "\u2728",
treasury: "\u{1F4B0}",
membership: "\u{1F465}",
"gov-ops": "\u{1F3DB}\uFE0F",
"fin-ops": "\u{1F4B0}",
"dev-ops": "\u{1F528}",
};
class FolkDelegationManager extends HTMLElement {

View File

@ -15,6 +15,7 @@ interface GraphNode {
location?: string;
description?: string;
trustScore?: number;
delegatedWeight?: number; // 0-1 normalized, computed from delegation edges
// 3d-force-graph internal properties
x?: number;
y?: number;
@ -27,10 +28,19 @@ interface GraphEdge {
type: string;
label?: string;
weight?: number;
authority?: string;
}
const DELEGATION_AUTHORITIES = ["voting", "moderation", "curation", "treasury", "membership"] as const;
const DELEGATION_AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const;
type DelegationAuthority = typeof DELEGATION_AUTHORITIES[number];
type AuthoritySelection = "all" | DelegationAuthority;
// Per-authority edge colors for "all" overlay mode (governance, economics, technology)
const AUTHORITY_COLORS: Record<string, string> = {
"gov-ops": "#a78bfa", // purple — governance decisions
"fin-ops": "#fbbf24", // amber — economic/financial decisions
"dev-ops": "#34d399", // green — technical decisions
};
// Node colors by type
const NODE_COLORS: Record<string, number> = {
@ -63,7 +73,7 @@ class FolkGraphViewer extends HTMLElement {
private error = "";
private selectedNode: GraphNode | null = null;
private trustMode = false;
private authority: DelegationAuthority = "voting";
private authority: AuthoritySelection = "gov-ops";
// 3D graph instance
private graph: any = null;
@ -102,7 +112,7 @@ class FolkGraphViewer extends HTMLElement {
private async loadData() {
const base = this.getApiBase();
try {
const trustParam = this.trustMode ? `?trust=true&authority=${this.authority}` : "";
const trustParam = this.trustMode ? `?trust=true&authority=${encodeURIComponent(this.authority)}` : "";
const [wsRes, infoRes, graphRes] = await Promise.all([
fetch(`${base}/api/workspaces`),
fetch(`${base}/api/info`),
@ -121,7 +131,7 @@ class FolkGraphViewer extends HTMLElement {
this.updateGraphData();
}
private async reloadWithAuthority(authority: DelegationAuthority) {
private async reloadWithAuthority(authority: AuthoritySelection) {
this.authority = authority;
this.trustMode = true;
await this.loadData();
@ -169,8 +179,27 @@ class FolkGraphViewer extends HTMLElement {
type: edgeTypeMap[e.type] || e.type,
label: e.label,
weight: e.weight,
authority: e.authority,
} as GraphEdge));
// Compute delegatedWeight for every node from delegation edges
const nodeWeights = new Map<string, number>();
for (const e of this.edges) {
if (e.type !== "delegates_to") continue;
const sid = typeof e.source === "string" ? e.source : e.source.id;
const tid = typeof e.target === "string" ? e.target : e.target.id;
const w = e.weight || 0.5;
nodeWeights.set(sid, (nodeWeights.get(sid) || 0) + w);
nodeWeights.set(tid, (nodeWeights.get(tid) || 0) + w);
}
const maxWeight = Math.max(...nodeWeights.values(), 1);
for (const node of this.nodes) {
const w = nodeWeights.get(node.id);
if (w != null) {
node.delegatedWeight = w / maxWeight;
}
}
// Assign company colors
const companies = this.nodes.filter(n => n.type === "company");
this.companyColors.clear();
@ -215,8 +244,14 @@ class FolkGraphViewer extends HTMLElement {
private getNodeRadius(node: GraphNode): number {
if (node.type === "company") return 22;
if (node.trustScore != null && this.trustMode) {
return 8 + (node.trustScore * 22);
if (this.trustMode) {
// Prefer edge-computed delegatedWeight, fall back to trustScore
if (node.delegatedWeight != null) {
return 6 + node.delegatedWeight * 24;
}
if (node.trustScore != null) {
return 6 + node.trustScore * 24;
}
}
return 12;
}
@ -308,7 +343,7 @@ class FolkGraphViewer extends HTMLElement {
.authority-btn:hover { border-color: var(--rs-border-strong); }
.authority-btn.active { border-color: #a78bfa; color: #a78bfa; background: rgba(167, 139, 250, 0.1); }
.legend { display: flex; gap: 16px; margin-top: 12px; }
.legend { display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--rs-text-muted); }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
.dot-person { background: #3b82f6; }
@ -387,6 +422,7 @@ class FolkGraphViewer extends HTMLElement {
</div>
<div class="authority-bar" id="authority-bar">
<button class="authority-btn" data-authority="all">All</button>
${DELEGATION_AUTHORITIES.map(a => `<button class="authority-btn" data-authority="${a}">${a}</button>`).join("")}
</div>
@ -409,6 +445,11 @@ class FolkGraphViewer extends HTMLElement {
<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" 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>
<span id="legend-authority-colors" style="display:none">
<div class="legend-item"><span class="legend-dot" style="background:#a78bfa"></span> Gov-Ops</div>
<div class="legend-item"><span class="legend-dot" style="background:#fbbf24"></span> Fin-Ops</div>
<div class="legend-item"><span class="legend-dot" style="background:#34d399"></span> Dev-Ops</div>
</span>
</div>
<div id="workspace-section"></div>
@ -449,7 +490,7 @@ class FolkGraphViewer extends HTMLElement {
// Authority buttons
this.shadow.querySelectorAll("[data-authority]").forEach(el => {
el.addEventListener("click", () => {
const authority = (el as HTMLElement).dataset.authority as DelegationAuthority;
const authority = (el as HTMLElement).dataset.authority as AuthoritySelection;
this.reloadWithAuthority(authority);
});
});
@ -514,21 +555,45 @@ class FolkGraphViewer extends HTMLElement {
.linkSource("source")
.linkTarget("target")
.linkColor((link: GraphEdge) => {
if (link.type === "delegates_to") {
if (this.authority === "all" && link.authority) {
return AUTHORITY_COLORS[link.authority] || EDGE_STYLES.delegates_to.color;
}
return EDGE_STYLES.delegates_to.color;
}
const style = EDGE_STYLES[link.type] || EDGE_STYLES.default;
return style.color;
})
.linkWidth((link: GraphEdge) => {
if (link.type === "delegates_to") {
return 1 + (link.weight || 0.5) * 3;
return 1 + (link.weight || 0.5) * 8;
}
const style = EDGE_STYLES[link.type] || EDGE_STYLES.default;
return style.width;
})
.linkCurvature((link: GraphEdge) =>
link.type === "delegates_to" ? 0.15 : 0
)
.linkCurveRotation("rotation")
.linkOpacity(0.6)
.linkDirectionalArrowLength((link: GraphEdge) =>
link.type === "delegates_to" ? 4 : 0
)
.linkDirectionalArrowRelPos(1)
.linkDirectionalParticles((link: GraphEdge) =>
link.type === "delegates_to" ? Math.ceil((link.weight || 0.5) * 4) : 0
)
.linkDirectionalParticleSpeed(0.004)
.linkDirectionalParticleWidth((link: GraphEdge) =>
link.type === "delegates_to" ? 1 + (link.weight || 0.5) * 2 : 0
)
.linkDirectionalParticleColor((link: GraphEdge) => {
if (link.type !== "delegates_to") return null;
if (this.authority === "all" && link.authority) {
return AUTHORITY_COLORS[link.authority] || "#c4b5fd";
}
return "#c4b5fd";
})
.linkLineDash((link: GraphEdge) => {
const style = EDGE_STYLES[link.type] || EDGE_STYLES.default;
return style.dashed ? [4, 2] : null;
@ -745,8 +810,10 @@ class FolkGraphViewer extends HTMLElement {
// Update legend visibility for trust mode
const membersLegend = this.shadow.getElementById("legend-members");
const delegatesLegend = this.shadow.getElementById("legend-delegates");
const authorityColors = this.shadow.getElementById("legend-authority-colors");
if (membersLegend) membersLegend.style.display = this.trustMode ? "" : "none";
if (delegatesLegend) delegatesLegend.style.display = this.trustMode ? "" : "none";
if (delegatesLegend) delegatesLegend.style.display = (this.trustMode && this.authority !== "all") ? "" : "none";
if (authorityColors) authorityColors.style.display = (this.trustMode && this.authority === "all") ? "" : "none";
// Fit view after data settles
setTimeout(() => {

View File

@ -29,13 +29,13 @@ interface TrustEvent {
createdAt: number;
}
const SANKEY_AUTHORITIES = ["voting", "moderation", "curation", "treasury", "membership"] as const;
const SANKEY_AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const;
const FLOW_COLOR = "#a78bfa";
class FolkTrustSankey extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private authority = "voting";
private authority = "gov-ops";
private flows: DelegationFlow[] = [];
private events: TrustEvent[] = [];
private loading = true;
@ -50,7 +50,7 @@ class FolkTrustSankey extends HTMLElement {
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.authority = this.getAttribute("authority") || "voting";
this.authority = this.getAttribute("authority") || "gov-ops";
this.render();
this.loadData();
}

View File

@ -1727,7 +1727,7 @@ export async function updatePushSubscriptionLastUsed(id: string): Promise<void>
// DELEGATIONS (person-to-person liquid democracy)
// ============================================================================
export type DelegationAuthority = 'voting' | 'moderation' | 'curation' | 'treasury' | 'membership' | 'custom';
export type DelegationAuthority = 'gov-ops' | 'fin-ops' | 'dev-ops' | 'custom';
export type DelegationState = 'active' | 'paused' | 'revoked';
export interface StoredDelegation {

View File

@ -376,7 +376,7 @@ CREATE TABLE IF NOT EXISTS delegations (
id TEXT PRIMARY KEY,
delegator_did TEXT NOT NULL,
delegate_did TEXT NOT NULL,
authority TEXT NOT NULL CHECK (authority IN ('voting', 'moderation', 'curation', 'treasury', 'membership', 'custom')),
authority TEXT NOT NULL CHECK (authority IN ('gov-ops', 'fin-ops', 'dev-ops', 'custom')),
weight REAL NOT NULL CHECK (weight > 0 AND weight <= 1),
max_depth INTEGER NOT NULL DEFAULT 3,
retain_authority BOOLEAN NOT NULL DEFAULT TRUE,
@ -452,3 +452,26 @@ CREATE TABLE IF NOT EXISTS legacy_identities (
CREATE INDEX IF NOT EXISTS idx_legacy_identities_user ON legacy_identities(user_id);
CREATE INDEX IF NOT EXISTS idx_legacy_identities_pubkey ON legacy_identities(legacy_public_key_hash);
-- ============================================================================
-- MIGRATION: Rename authority verticals (voting/moderation/curation/treasury/membership → gov-ops/fin-ops/dev-ops)
-- ============================================================================
-- Migrate existing delegation data
UPDATE delegations SET authority = 'gov-ops' WHERE authority IN ('voting', 'moderation');
UPDATE delegations SET authority = 'fin-ops' WHERE authority = 'treasury';
UPDATE delegations SET authority = 'dev-ops' WHERE authority IN ('curation', 'membership');
-- Migrate existing trust_scores data
UPDATE trust_scores SET authority = 'gov-ops' WHERE authority IN ('voting', 'moderation');
UPDATE trust_scores SET authority = 'fin-ops' WHERE authority = 'treasury';
UPDATE trust_scores SET authority = 'dev-ops' WHERE authority IN ('curation', 'membership');
-- Migrate existing trust_events data
UPDATE trust_events SET authority = 'gov-ops' WHERE authority IN ('voting', 'moderation');
UPDATE trust_events SET authority = 'fin-ops' WHERE authority = 'treasury';
UPDATE trust_events SET authority = 'dev-ops' WHERE authority IN ('curation', 'membership');
-- Update CHECK constraint on delegations table
ALTER TABLE delegations DROP CONSTRAINT IF EXISTS delegations_authority_check;
ALTER TABLE delegations ADD CONSTRAINT delegations_authority_check CHECK (authority IN ('gov-ops', 'fin-ops', 'dev-ops', 'custom'));

View File

@ -7122,7 +7122,7 @@ app.get('/', (c) => {
// DELEGATION ROUTES (person-to-person liquid democracy)
// ============================================================================
const VALID_AUTHORITIES = ['voting', 'moderation', 'curation', 'treasury', 'membership', 'custom'];
const VALID_AUTHORITIES = ['gov-ops', 'fin-ops', 'dev-ops', 'custom'];
// POST /api/delegations — create a new delegation
app.post('/api/delegations', async (c) => {
@ -7359,7 +7359,7 @@ app.get('/api/delegations/space', async (c) => {
// GET /api/trust/scores — aggregated trust scores for visualization
app.get('/api/trust/scores', async (c) => {
const authority = c.req.query('authority') || 'voting';
const authority = c.req.query('authority') || 'gov-ops';
const spaceSlug = c.req.query('space');
if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);

View File

@ -167,7 +167,7 @@ export function computeTrustScores(
// ── Background Recomputation ──
const AUTHORITIES: DelegationAuthority[] = ['voting', 'moderation', 'curation', 'treasury', 'membership', 'custom'];
const AUTHORITIES: DelegationAuthority[] = ['gov-ops', 'fin-ops', 'dev-ops', 'custom'];
/**
* Recompute all trust scores for a single space.