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) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-04 02:07:13 +00:00 committed by Jeff Emmett
parent 3a3b83c807
commit a52cad4ee6
1 changed files with 111 additions and 8 deletions

View File

@ -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
? '<span style="color:#22c55e">&#x2714;</span>'
: '<span style="color:#ef4444">&#x2718;</span>';
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 ? '&#x2714;' : '';
return `
<div style="font-size:11px;color:#94a3b8;margin-top:2px">Assignee: ${esc(c.assignee || 'Unassigned')}</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:4px">
${icon}
<span style="font-size:12px;color:#e2e8f0">${satisfied}</span>
<div class="gc-signoff-toggle" data-node-id="${node.id}" style="
display:flex;align-items:center;gap:8px;margin-top:6px;
cursor:pointer;padding:4px 8px;border-radius:6px;
background:${checkBg};border:1px solid ${checkColor}40;
transition:all 0.15s;user-select:none;
-webkit-tap-highlight-color:transparent;
">
<div style="
width:20px;height:20px;border-radius:4px;
border:2px solid ${checkColor};
display:flex;align-items:center;justify-content:center;
font-size:14px;color:${checkColor};
background:${c.satisfied ? checkColor + '20' : 'transparent'};
flex-shrink:0;transition:all 0.15s;
">${checkIcon}</div>
<span style="font-size:12px;color:#e2e8f0;font-weight:500">${satisfied}</span>
${c.satisfied && c.signedBy ? `<span style="font-size:10px;color:#64748b;margin-left:auto">by ${esc(c.signedBy)}</span>` : ''}
</div>`;
}
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<string, any> {
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 };