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 };