From df8901c9756b112fe29ee830a31f6d73faffdf21 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 4 Apr 2026 02:07:13 +0000 Subject: [PATCH] feat(rgov): clickable signoff gates with authority check + project recalc Binary signoff nodes now have a clickable checkbox that toggles satisfied state. Checks EncryptID JWT for authority (assignee match), falls back to allowing anyone in demo mode. Toggling a signoff auto-recalculates connected project aggregator gate counts. Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/rgov/components/folk-gov-circuit.ts | 119 ++++++++++++++++++-- 1 file changed, 111 insertions(+), 8 deletions(-) diff --git a/modules/rgov/components/folk-gov-circuit.ts b/modules/rgov/components/folk-gov-circuit.ts index b8bae48..e985bc1 100644 --- a/modules/rgov/components/folk-gov-circuit.ts +++ b/modules/rgov/components/folk-gov-circuit.ts @@ -164,7 +164,7 @@ function buildDemoData(): { nodes: GovNode[]; edges: GovEdge[] } { nodes.push({ id: 'c2-venue', type: 'folk-gov-binary', label: 'Venue Signoff', position: { x: 50, y: c2y + 320 }, - config: { assignee: 'Carlos', satisfied: true }, + config: { assignee: 'Carlos', satisfied: true, signedBy: 'Carlos' }, }); nodes.push({ id: 'c2-project', type: 'folk-gov-project', label: 'Community Potluck', @@ -247,14 +247,28 @@ function renderNodeBody(node: GovNode): string { switch (node.type) { case 'folk-gov-binary': { const satisfied = c.satisfied ? 'Yes' : 'No'; - const icon = c.satisfied - ? '' - : ''; + const checkColor = c.satisfied ? '#22c55e' : '#475569'; + const checkBg = c.satisfied ? 'rgba(34,197,94,0.15)' : 'rgba(71,85,105,0.15)'; + const checkIcon = c.satisfied ? '✔' : ''; return `
Assignee: ${esc(c.assignee || 'Unassigned')}
-
- ${icon} - ${satisfied} +
+
${checkIcon}
+ ${satisfied} + ${c.satisfied && c.signedBy ? `by ${esc(c.signedBy)}` : ''}
`; } case 'folk-gov-threshold': { @@ -889,6 +903,7 @@ export class FolkGovCircuit extends HTMLElement { const demo = buildDemoData(); this.nodes = demo.nodes; this.edges = demo.edges; + this.recalcProjectGates(); this.render(); requestAnimationFrame(() => this.fitView()); } @@ -1268,6 +1283,94 @@ export class FolkGovCircuit extends HTMLElement { edgeLayer.innerHTML = this.renderAllEdges(); nodeLayer.innerHTML = this.renderAllNodes(); if (wireLayer) wireLayer.innerHTML = ''; + this.attachSignoffHandlers(); + } + + private attachSignoffHandlers() { + const nodeLayer = this.shadow.getElementById('node-layer'); + if (!nodeLayer) return; + nodeLayer.querySelectorAll('.gc-signoff-toggle').forEach(el => { + el.addEventListener('click', (e: Event) => { + e.stopPropagation(); + e.preventDefault(); + const nodeId = (el as HTMLElement).dataset.nodeId; + if (!nodeId) return; + this.toggleSignoff(nodeId); + }); + // Prevent the click from starting a node drag + el.addEventListener('pointerdown', (e: Event) => { + e.stopPropagation(); + }); + }); + } + + private toggleSignoff(nodeId: string) { + const node = this.nodes.find(n => n.id === nodeId); + if (!node || node.type !== 'folk-gov-binary') return; + + // Authority check: resolve current user from EncryptID JWT if available + let currentUser = 'You'; + try { + const token = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('auth=')); + if (token) { + const payload = JSON.parse(atob(token.split('=')[1].split('.')[1])); + currentUser = payload.username || payload.sub || 'You'; + } + } catch { /* no auth context — allow toggle for demo */ } + + // Check authority: if assignee is set, only the assignee (or admin) can toggle + const assignee = (node.config.assignee || '').toLowerCase().trim(); + const userLower = currentUser.toLowerCase().trim(); + if (assignee && assignee !== 'unassigned' && !assignee.includes('pending')) { + // Extract just the name part (handle "Carlos", "Landlord (pending)", etc.) + const assigneeName = assignee.replace(/\s*\(.*\)\s*$/, ''); + if (assigneeName && assigneeName !== userLower && userLower !== 'you') { + // Not authorized — show brief feedback + const el = this.shadow.querySelector(`[data-node-id="${nodeId}"].gc-signoff-toggle`) as HTMLElement; + if (el) { + el.style.outline = '2px solid #ef4444'; + el.style.outlineOffset = '2px'; + setTimeout(() => { el.style.outline = ''; el.style.outlineOffset = ''; }, 600); + } + return; + } + } + + // Toggle + node.config.satisfied = !node.config.satisfied; + node.config.signedBy = node.config.satisfied ? currentUser : ''; + + // Update connected project aggregators + this.recalcProjectGates(); + + // Re-render + this.drawCanvasContent(); + if (this.selectedNodeId === nodeId) { + this.refreshDetailPanel(); + } + } + + private recalcProjectGates() { + // For each project node, count how many of its incoming edges come from satisfied gates + for (const node of this.nodes) { + if (node.type !== 'folk-gov-project') continue; + const incomingEdges = this.edges.filter(e => e.toNode === node.id); + let satisfied = 0; + let total = incomingEdges.length; + for (const edge of incomingEdges) { + const source = this.nodes.find(n => n.id === edge.fromNode); + if (!source) continue; + if (source.type === 'folk-gov-binary' && source.config.satisfied) satisfied++; + else if (source.type === 'folk-gov-threshold' && source.config.current >= source.config.target) satisfied++; + else if (source.type === 'folk-gov-multisig') { + const signed = (source.config.signed || '').split(',').filter((s: string) => s.trim()).length; + if (signed >= source.config.required) satisfied++; + } + else if (source.type === 'folk-gov-conviction' && source.config.accumulated >= source.config.threshold) satisfied++; + } + node.config.gatesSatisfied = satisfied; + node.config.gatesTotal = total; + } } private redrawEdges() { @@ -1331,7 +1434,7 @@ export class FolkGovCircuit extends HTMLElement { private defaultConfigFor(type: string): Record { switch (type) { - case 'folk-gov-binary': return { assignee: '', satisfied: false }; + case 'folk-gov-binary': return { assignee: '', satisfied: false, signedBy: '' }; case 'folk-gov-threshold': return { target: 100, current: 0, unit: '', contributors: '' }; case 'folk-gov-knob': return { min: 0, max: 100, value: 50, unit: '' }; case 'folk-gov-project': return { description: '', gatesSatisfied: 0, gatesTotal: 0 };