diff --git a/modules/rgov/components/folk-gov-circuit.ts b/modules/rgov/components/folk-gov-circuit.ts new file mode 100644 index 0000000..924c185 --- /dev/null +++ b/modules/rgov/components/folk-gov-circuit.ts @@ -0,0 +1,1555 @@ +/** + * — n8n-style interactive mini-canvas for governance + * decision circuits in rGov. + * + * Renders governance-specific workflow nodes (signoff, threshold, knob, + * project, quadratic, conviction, multisig, sankey) on an SVG canvas + * with ports, Bezier wiring, node palette, detail panel, and fit-view. + * + * Standalone canvas — no Automerge, no server calls. Pure client-side + * with pre-loaded demo data. + * + * Attributes: + * circuit — which demo circuit to show (default: all) + */ + +// ── Types ── + +interface GovNodeDef { + type: string; + label: string; + icon: string; + color: string; + inputs: { name: string }[]; + outputs: { name: string }[]; +} + +interface GovNode { + id: string; + type: string; + label: string; + position: { x: number; y: number }; + config: Record; +} + +interface GovEdge { + id: string; + fromNode: string; + fromPort: string; + toNode: string; + toPort: string; +} + +// ── Constants ── + +const NODE_WIDTH = 240; +const NODE_HEIGHT = 120; +const PORT_RADIUS = 6; + +const GOV_NODE_CATALOG: GovNodeDef[] = [ + { + type: 'folk-gov-binary', + label: 'Signoff', + icon: '\u2714', + color: '#7c3aed', + inputs: [{ name: 'in' }], + outputs: [{ name: 'out' }], + }, + { + type: 'folk-gov-threshold', + label: 'Threshold', + icon: '\u2593', + color: '#0891b2', + inputs: [{ name: 'in' }], + outputs: [{ name: 'out' }], + }, + { + type: 'folk-gov-knob', + label: 'Knob', + icon: '\u2699', + color: '#b45309', + inputs: [{ name: 'in' }], + outputs: [{ name: 'out' }], + }, + { + type: 'folk-gov-project', + label: 'Project', + icon: '\u25A3', + color: '#10b981', + inputs: [{ name: 'in' }], + outputs: [{ name: 'out' }], + }, + { + type: 'folk-gov-quadratic', + label: 'Quadratic', + icon: '\u221A', + color: '#14b8a6', + inputs: [{ name: 'in' }], + outputs: [{ name: 'out' }], + }, + { + type: 'folk-gov-conviction', + label: 'Conviction', + icon: '\u23F1', + color: '#d97706', + inputs: [{ name: 'in' }], + outputs: [{ name: 'out' }], + }, + { + type: 'folk-gov-multisig', + label: 'Multisig', + icon: '\u{1F511}', + color: '#6366f1', + inputs: [{ name: 'in' }], + outputs: [{ name: 'out' }], + }, + { + type: 'folk-gov-sankey', + label: 'Sankey', + icon: '\u2B82', + color: '#f43f5e', + inputs: [{ name: 'in' }], + outputs: [{ name: 'out' }], + }, +]; + +// ── Demo Data ── + +function buildDemoData(): { nodes: GovNode[]; edges: GovEdge[] } { + const nodes: GovNode[] = []; + const edges: GovEdge[] = []; + let eid = 0; + const mkEdge = (from: string, fp: string, to: string, tp: string) => { + edges.push({ id: `ge-${++eid}`, fromNode: from, fromPort: fp, toNode: to, toPort: tp }); + }; + + // ── Circuit 1: Build a Climbing Wall ── + const c1y = 40; + nodes.push({ + id: 'c1-labor', type: 'folk-gov-threshold', label: 'Labor Threshold', + position: { x: 50, y: c1y }, + config: { target: 50, current: 20, unit: 'hours', contributors: 'Alice: 8h, Bob: 6h, Carol: 6h' }, + }); + nodes.push({ + id: 'c1-capital', type: 'folk-gov-threshold', label: 'Capital Threshold', + position: { x: 50, y: c1y + 160 }, + config: { target: 3000, current: 2000, unit: '$', contributors: 'Fund A: $1200, Dave: $800' }, + }); + nodes.push({ + id: 'c1-signoff', type: 'folk-gov-binary', label: 'Proprietor Signoff', + position: { x: 50, y: c1y + 320 }, + config: { assignee: 'Landlord (pending)', satisfied: false }, + }); + nodes.push({ + id: 'c1-project', type: 'folk-gov-project', label: 'Build a Climbing Wall', + position: { x: 400, y: c1y + 140 }, + config: { description: 'Community climbing wall in the courtyard', gatesSatisfied: 1, gatesTotal: 3 }, + }); + mkEdge('c1-labor', 'out', 'c1-project', 'in'); + mkEdge('c1-capital', 'out', 'c1-project', 'in'); + mkEdge('c1-signoff', 'out', 'c1-project', 'in'); + + // ── Circuit 2: Community Potluck ── + const c2y = c1y + 520; + nodes.push({ + id: 'c2-budget', type: 'folk-gov-knob', label: 'Budget Knob', + position: { x: 50, y: c2y }, + config: { min: 100, max: 5000, value: 1500, unit: '$' }, + }); + nodes.push({ + id: 'c2-rsvps', type: 'folk-gov-threshold', label: 'RSVPs', + position: { x: 50, y: c2y + 160 }, + config: { target: 20, current: 14, unit: 'people', contributors: '14 confirmed attendees' }, + }); + nodes.push({ + id: 'c2-venue', type: 'folk-gov-binary', label: 'Venue Signoff', + position: { x: 50, y: c2y + 320 }, + config: { assignee: 'Carlos', satisfied: true }, + }); + nodes.push({ + id: 'c2-project', type: 'folk-gov-project', label: 'Community Potluck', + position: { x: 400, y: c2y + 140 }, + config: { description: 'Monthly community potluck event', gatesSatisfied: 2, gatesTotal: 3 }, + }); + mkEdge('c2-budget', 'out', 'c2-project', 'in'); + mkEdge('c2-rsvps', 'out', 'c2-project', 'in'); + mkEdge('c2-venue', 'out', 'c2-project', 'in'); + + // ── Circuit 3: Delegated Budget Approval ── + const c3y = c2y + 520; + nodes.push({ + id: 'c3-quad', type: 'folk-gov-quadratic', label: 'Quadratic Dampener', + position: { x: 50, y: c3y }, + config: { mode: 'sqrt', entries: 'Alice: 100, Bob: 49, Carol: 25' }, + }); + nodes.push({ + id: 'c3-conviction', type: 'folk-gov-conviction', label: 'Conviction Accumulator', + position: { x: 50, y: c3y + 160 }, + config: { threshold: 500, accumulated: 320, stakes: 'Alice: 150, Bob: 100, Carol: 70' }, + }); + nodes.push({ + id: 'c3-multisig', type: 'folk-gov-multisig', label: '3-of-5 Multisig', + position: { x: 50, y: c3y + 320 }, + config: { required: 3, total: 5, signers: 'Alice, Bob, Carol, Dave, Eve', signed: 'Alice, Bob' }, + }); + nodes.push({ + id: 'c3-project', type: 'folk-gov-project', label: 'Delegated Budget Approval', + position: { x: 400, y: c3y + 140 }, + config: { description: 'Multi-mechanism budget governance', gatesSatisfied: 0, gatesTotal: 3 }, + }); + nodes.push({ + id: 'c3-sankey', type: 'folk-gov-sankey', label: 'Flow Visualizer', + position: { x: 700, y: c3y + 140 }, + config: { note: 'Decorative flow diagram' }, + }); + mkEdge('c3-quad', 'out', 'c3-project', 'in'); + mkEdge('c3-conviction', 'out', 'c3-project', 'in'); + mkEdge('c3-multisig', 'out', 'c3-project', 'in'); + + return { nodes, edges }; +} + +// ── Helpers ── + +function esc(s: string): string { + const d = document.createElement('div'); + d.textContent = s || ''; + return d.innerHTML; +} + +function getNodeDef(type: string): GovNodeDef | undefined { + return GOV_NODE_CATALOG.find(n => n.type === type); +} + +function getPortX(node: GovNode, _portName: string, direction: 'input' | 'output'): number { + return direction === 'input' ? node.position.x : node.position.x + NODE_WIDTH; +} + +function getPortY(node: GovNode, _portName: string, direction: 'input' | 'output'): number { + const def = getNodeDef(node.type); + if (!def) return node.position.y + NODE_HEIGHT / 2; + const ports = direction === 'input' ? def.inputs : def.outputs; + const idx = ports.findIndex(p => p.name === _portName); + if (idx === -1) return node.position.y + NODE_HEIGHT / 2; + const spacing = NODE_HEIGHT / (ports.length + 1); + return node.position.y + spacing * (idx + 1); +} + +function bezierPath(x1: number, y1: number, x2: number, y2: number): string { + const dx = Math.abs(x2 - x1) * 0.5; + return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`; +} + +// ── Node body renderers ── + +function renderNodeBody(node: GovNode): string { + const c = node.config; + switch (node.type) { + case 'folk-gov-binary': { + const satisfied = c.satisfied ? 'Yes' : 'No'; + const icon = c.satisfied + ? '' + : ''; + return ` +
Assignee: ${esc(c.assignee || 'Unassigned')}
+
+ ${icon} + ${satisfied} +
`; + } + case 'folk-gov-threshold': { + const pct = c.target > 0 ? Math.min(100, Math.round((c.current / c.target) * 100)) : 0; + return ` +
+ ${c.current}/${c.target} ${esc(c.unit || '')} + ${pct}% +
+
+
+
`; + } + case 'folk-gov-knob': { + const pct = c.max > c.min ? Math.round(((c.value - c.min) / (c.max - c.min)) * 100) : 50; + return ` +
+ ${esc(c.unit || '')}${c.min} \u2014 ${esc(c.unit || '')}${c.max} +
+
+
+
+
+
+ ${esc(c.unit || '')}${c.value} +
`; + } + case 'folk-gov-project': { + const sat = c.gatesSatisfied || 0; + const tot = c.gatesTotal || 0; + return ` +
${esc(c.description || '')}
+
+ ${sat} of ${tot} gates satisfied + ${sat >= tot ? '' : ''} +
`; + } + case 'folk-gov-quadratic': { + const mode = c.mode || 'sqrt'; + return ` +
Mode: ${esc(mode)}
+
${esc(c.entries || '')}
`; + } + case 'folk-gov-conviction': { + const pct = c.threshold > 0 ? Math.min(100, Math.round((c.accumulated / c.threshold) * 100)) : 0; + return ` +
+ Score: ${c.accumulated}/${c.threshold} + ${pct}% +
+
+
+
`; + } + case 'folk-gov-multisig': { + const signed = (c.signed || '').split(',').map((s: string) => s.trim()).filter(Boolean); + const all = (c.signers || '').split(',').map((s: string) => s.trim()).filter(Boolean); + const checks = all.map((s: string) => { + const ok = signed.includes(s); + return `${ok ? '\u2714' : '\u25CB'}`; + }).join(' '); + return ` +
${c.required} of ${c.total} required
+
${checks}
`; + } + case 'folk-gov-sankey': { + return ` +
Flow visualization
+
+
+
+
+
+
+
`; + } + default: + return ''; + } +} + +// ── Styles ── + +const STYLES = ` +:host { + display: block; + width: 100%; + height: 100%; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + color: #e2e8f0; +} + +.gc-root { + display: flex; + flex-direction: column; + height: 100%; + background: #0f172a; +} + +/* ── Toolbar ── */ + +.gc-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: #1e293b; + border-bottom: 1px solid #334155; + flex-shrink: 0; + gap: 12px; +} + +.gc-toolbar__title { + display: flex; + align-items: center; + gap: 12px; + font-size: 14px; + font-weight: 600; + color: #f1f5f9; +} + +.gc-toolbar__actions { + display: flex; + align-items: center; + gap: 8px; +} + +.gc-btn { + padding: 4px 12px; + border: 1px solid #334155; + border-radius: 6px; + background: #1e293b; + color: #cbd5e1; + font-size: 12px; + cursor: pointer; + transition: background 0.15s; +} + +.gc-btn:hover { + background: #334155; +} + +.gc-btn--fit { + color: #38bdf8; + border-color: #38bdf8; +} + +/* ── Canvas area ── */ + +.gc-canvas-area { + display: flex; + flex: 1; + overflow: hidden; + position: relative; +} + +/* ── Palette ── */ + +.gc-palette { + width: 180px; + flex-shrink: 0; + background: #1e293b; + border-right: 1px solid #334155; + overflow-y: auto; + padding: 12px 8px; +} + +.gc-palette__title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #64748b; + margin-bottom: 8px; +} + +.gc-palette__card { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid #334155; + background: #0f172a; + margin-bottom: 4px; + cursor: grab; + transition: border-color 0.15s, background 0.15s; + font-size: 12px; +} + +.gc-palette__card:hover { + border-color: #475569; + background: #1e293b; +} + +.gc-palette__card-color { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.gc-palette__card-label { + color: #cbd5e1; +} + +/* ── SVG Canvas ── */ + +.gc-canvas { + flex: 1; + position: relative; + overflow: hidden; + cursor: grab; +} + +.gc-canvas.grabbing { + cursor: grabbing; +} + +.gc-canvas.wiring { + cursor: crosshair; +} + +.gc-canvas svg { + width: 100%; + height: 100%; + display: block; +} + +/* ── Grid ── */ + +.gc-grid-pattern line { + stroke: #1e293b; + stroke-width: 1; +} + +/* ── Zoom controls ── */ + +.gc-zoom-controls { + position: absolute; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 0; + background: #1e293b; + border: 1px solid #334155; + border-radius: 8px; + padding: 2px 4px; +} + +.gc-zoom-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + background: transparent; + color: #94a3b8; + cursor: pointer; + border-radius: 4px; +} + +.gc-zoom-btn:hover { + background: #334155; + color: #e2e8f0; +} + +.gc-zoom-level { + font-size: 11px; + color: #64748b; + min-width: 40px; + text-align: center; + user-select: none; +} + +.gc-zoom-sep { + width: 1px; + height: 16px; + background: #334155; + margin: 0 2px; +} + +/* ── Edges ── */ + +.gc-edge-path { + fill: none; + stroke-width: 2; + pointer-events: none; +} + +.gc-edge-hit { + fill: none; + stroke: transparent; + stroke-width: 12; + cursor: pointer; +} + +.gc-edge-hit:hover + .gc-edge-path { + stroke-opacity: 1; + stroke-width: 3; +} + +/* ── Wiring temp ── */ + +.gc-wiring-temp { + fill: none; + stroke: #38bdf8; + stroke-width: 2; + stroke-dasharray: 6 4; + pointer-events: none; +} + +/* ── Ports ── */ + +.gc-port-dot { + transition: r 0.1s; +} + +.gc-port-hit { + cursor: crosshair; +} + +.gc-port-hit:hover ~ .gc-port-dot, +.gc-port-group:hover .gc-port-dot { + r: 8; +} + +/* ── Detail panel ── */ + +.gc-detail { + width: 0; + overflow: hidden; + background: #1e293b; + border-left: 1px solid #334155; + transition: width 0.2s; + flex-shrink: 0; +} + +.gc-detail.open { + width: 280px; +} + +.gc-detail__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #334155; + font-size: 13px; + font-weight: 600; +} + +.gc-detail__close { + background: none; + border: none; + color: #64748b; + font-size: 18px; + cursor: pointer; + padding: 0 4px; +} + +.gc-detail__close:hover { + color: #e2e8f0; +} + +.gc-detail__body { + padding: 12px 16px; + overflow-y: auto; + max-height: calc(100% - 48px); +} + +.gc-detail__field { + margin-bottom: 12px; +} + +.gc-detail__field label { + display: block; + font-size: 11px; + color: #64748b; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.gc-detail__field input, +.gc-detail__field textarea, +.gc-detail__field select { + width: 100%; + padding: 6px 8px; + border: 1px solid #334155; + border-radius: 4px; + background: #0f172a; + color: #e2e8f0; + font-size: 12px; + box-sizing: border-box; +} + +.gc-detail__field textarea { + resize: vertical; + min-height: 60px; +} + +.gc-detail__delete { + width: 100%; + padding: 8px; + margin-top: 12px; + border: 1px solid #7f1d1d; + border-radius: 6px; + background: #450a0a; + color: #fca5a5; + font-size: 12px; + cursor: pointer; +} + +.gc-detail__delete:hover { + background: #7f1d1d; +} +`; + +// ── Component ── + +export class FolkGovCircuit extends HTMLElement { + private shadow: ShadowRoot; + + // Data + private nodes: GovNode[] = []; + private edges: GovEdge[] = []; + + // Canvas state + private canvasZoom = 1; + private canvasPanX = 0; + private canvasPanY = 0; + private showGrid = true; + + // Interaction + private isPanning = false; + private panStartX = 0; + private panStartY = 0; + private panStartPanX = 0; + private panStartPanY = 0; + private draggingNodeId: string | null = null; + private dragStartX = 0; + private dragStartY = 0; + private dragNodeStartX = 0; + private dragNodeStartY = 0; + + // Selection & detail panel + private selectedNodeId: string | null = null; + private detailOpen = false; + + // Wiring + private wiringActive = false; + private wiringSourceNodeId: string | null = null; + private wiringSourcePortName: string | null = null; + private wiringPointerX = 0; + private wiringPointerY = 0; + + // Bound listeners + private _boundPointerMove: ((e: PointerEvent) => void) | null = null; + private _boundPointerUp: ((e: PointerEvent) => void) | null = null; + private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: 'open' }); + } + + static get observedAttributes() { return ['circuit']; } + + connectedCallback() { + this.initData(); + } + + disconnectedCallback() { + if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove); + if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp); + if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown); + } + + // ── Data init ── + + private initData() { + const demo = buildDemoData(); + this.nodes = demo.nodes; + this.edges = demo.edges; + this.render(); + requestAnimationFrame(() => this.fitView()); + } + + // ── Canvas transform ── + + private updateCanvasTransform() { + const g = this.shadow.getElementById('canvas-transform'); + if (g) g.setAttribute('transform', `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`); + this.updateZoomDisplay(); + } + + private updateZoomDisplay() { + const el = this.shadow.getElementById('zoom-level'); + if (el) el.textContent = `${Math.round(this.canvasZoom * 100)}%`; + } + + private fitView() { + const svg = this.shadow.getElementById('gc-svg') as SVGSVGElement | null; + if (!svg || this.nodes.length === 0) return; + const rect = svg.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const n of this.nodes) { + minX = Math.min(minX, n.position.x); + minY = Math.min(minY, n.position.y); + maxX = Math.max(maxX, n.position.x + NODE_WIDTH); + maxY = Math.max(maxY, n.position.y + NODE_HEIGHT); + } + + const pad = 60; + const contentW = maxX - minX + pad * 2; + const contentH = maxY - minY + pad * 2; + const scaleX = rect.width / contentW; + const scaleY = rect.height / contentH; + this.canvasZoom = Math.min(scaleX, scaleY, 1.5); + this.canvasPanX = (rect.width - contentW * this.canvasZoom) / 2 - (minX - pad) * this.canvasZoom; + this.canvasPanY = (rect.height - contentH * this.canvasZoom) / 2 - (minY - pad) * this.canvasZoom; + this.updateCanvasTransform(); + } + + private zoomAt(screenX: number, screenY: number, factor: number) { + const oldZoom = this.canvasZoom; + const newZoom = Math.max(0.1, Math.min(4, oldZoom * factor)); + this.canvasPanX = screenX - (screenX - this.canvasPanX) * (newZoom / oldZoom); + this.canvasPanY = screenY - (screenY - this.canvasPanY) * (newZoom / oldZoom); + this.canvasZoom = newZoom; + this.updateCanvasTransform(); + } + + // ── Rendering ── + + private render() { + const gridDef = this.showGrid ? ` + + + + + + + ` : ''; + + this.shadow.innerHTML = ` + +
+
+
+ \u25A3 Governance Circuits +
+
+ + + + +
+
+ +
+
+
Node Types
+ ${GOV_NODE_CATALOG.map(n => ` +
+
+ ${n.icon} ${esc(n.label)} +
+ `).join('')} +
+ +
+ + + ${gridDef} + ${this.renderAllEdges()} + + ${this.renderAllNodes()} + + +
+ + + ${Math.round(this.canvasZoom * 100)}% + + + + +
+
+ +
+ ${this.renderDetailPanel()} +
+
+
+ `; + + this.attachEventListeners(); + } + + private renderAllNodes(): string { + return this.nodes.map(node => this.renderNode(node)).join(''); + } + + private renderNode(node: GovNode): string { + const def = getNodeDef(node.type); + if (!def) return ''; + const isSelected = node.id === this.selectedNodeId; + + // Ports + let portsHtml = ''; + for (const inp of def.inputs) { + const y = getPortY(node, inp.name, 'input'); + const x = node.position.x; + portsHtml += ` + + + + `; + } + for (const out of def.outputs) { + const y = getPortY(node, out.name, 'output'); + const x = node.position.x + NODE_WIDTH; + portsHtml += ` + + + + `; + } + + const bodyHtml = renderNodeBody(node); + + return ` + + +
+
+
+
+ ${def.icon} + ${esc(node.label)} +
+ ${bodyHtml} +
+
+
+ ${portsHtml} +
`; + } + + private renderAllEdges(): string { + return this.edges.map(edge => { + const fromNode = this.nodes.find(n => n.id === edge.fromNode); + const toNode = this.nodes.find(n => n.id === edge.toNode); + if (!fromNode || !toNode) return ''; + + const x1 = getPortX(fromNode, edge.fromPort, 'output'); + const y1 = getPortY(fromNode, edge.fromPort, 'output'); + const x2 = getPortX(toNode, edge.toPort, 'input'); + const y2 = getPortY(toNode, edge.toPort, 'input'); + + const fromDef = getNodeDef(fromNode.type); + const color = fromDef ? fromDef.color : '#6b7280'; + const d = bezierPath(x1, y1, x2, y2); + + return ` + + + + `; + }).join(''); + } + + private renderDetailPanel(): string { + if (!this.selectedNodeId) { + return ` +
+ No node selected + +
+
+

Click a node to view details.

+
`; + } + + const node = this.nodes.find(n => n.id === this.selectedNodeId); + if (!node) return ''; + const def = getNodeDef(node.type); + if (!def) return ''; + + const fieldsHtml = this.renderDetailFields(node); + + return ` +
+ ${def.icon} ${esc(node.label)} + +
+
+
+ + +
+
+ + +
+ ${fieldsHtml} + +
`; + } + + private renderDetailFields(node: GovNode): string { + const c = node.config; + switch (node.type) { + case 'folk-gov-binary': + return ` +
+ + +
+
+ + +
`; + case 'folk-gov-threshold': + return ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
`; + case 'folk-gov-knob': + return ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
`; + case 'folk-gov-project': + return ` +
+ + +
+
+ + +
+
+ + +
`; + case 'folk-gov-quadratic': + return ` +
+ + +
+
+ + +
`; + case 'folk-gov-conviction': + return ` +
+ + +
+
+ + +
+
+ + +
`; + case 'folk-gov-multisig': + return ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
`; + case 'folk-gov-sankey': + return ` +
+ + +
`; + default: + return ''; + } + } + + // ── Redraw helpers ── + + private drawCanvasContent() { + const edgeLayer = this.shadow.getElementById('edge-layer'); + const nodeLayer = this.shadow.getElementById('node-layer'); + const wireLayer = this.shadow.getElementById('wire-layer'); + if (!edgeLayer || !nodeLayer) return; + edgeLayer.innerHTML = this.renderAllEdges(); + nodeLayer.innerHTML = this.renderAllNodes(); + if (wireLayer) wireLayer.innerHTML = ''; + } + + private redrawEdges() { + const edgeLayer = this.shadow.getElementById('edge-layer'); + if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges(); + } + + private updateNodePosition(node: GovNode) { + const nodeLayer = this.shadow.getElementById('node-layer'); + if (!nodeLayer) return; + const g = nodeLayer.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null; + if (!g) return; + const fo = g.querySelector('foreignObject'); + if (fo) { + fo.setAttribute('x', String(node.position.x)); + fo.setAttribute('y', String(node.position.y)); + } + const def = getNodeDef(node.type); + if (!def) return; + const portGroups = g.querySelectorAll('.gc-port-group'); + portGroups.forEach(pg => { + const portName = (pg as HTMLElement).dataset.portName!; + const dir = (pg as HTMLElement).dataset.portDir as 'input' | 'output'; + const x = dir === 'input' ? node.position.x : node.position.x + NODE_WIDTH; + const ports = dir === 'input' ? def.inputs : def.outputs; + const idx = ports.findIndex(p => p.name === portName); + const spacing = NODE_HEIGHT / (ports.length + 1); + const y = node.position.y + spacing * (idx + 1); + pg.querySelectorAll('circle').forEach(c => { + c.setAttribute('cx', String(x)); + c.setAttribute('cy', String(y)); + }); + }); + } + + private refreshDetailPanel() { + const panel = this.shadow.getElementById('detail-panel'); + if (!panel) return; + panel.className = `gc-detail ${this.detailOpen ? 'open' : ''}`; + panel.innerHTML = this.renderDetailPanel(); + this.attachDetailListeners(); + } + + // ── Node operations ── + + private addNode(type: string, x: number, y: number) { + const def = getNodeDef(type); + if (!def) return; + const id = `gn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node: GovNode = { + id, + type: def.type, + label: def.label, + position: { x, y }, + config: this.defaultConfigFor(type), + }; + this.nodes.push(node); + this.drawCanvasContent(); + this.selectNode(id); + } + + private defaultConfigFor(type: string): Record { + switch (type) { + case 'folk-gov-binary': return { assignee: '', satisfied: false }; + 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 }; + case 'folk-gov-quadratic': return { mode: 'sqrt', entries: '' }; + case 'folk-gov-conviction': return { threshold: 100, accumulated: 0, stakes: '' }; + case 'folk-gov-multisig': return { required: 2, total: 3, signers: '', signed: '' }; + case 'folk-gov-sankey': return { note: '' }; + default: return {}; + } + } + + private deleteNode(nodeId: string) { + this.nodes = this.nodes.filter(n => n.id !== nodeId); + this.edges = this.edges.filter(e => e.fromNode !== nodeId && e.toNode !== nodeId); + if (this.selectedNodeId === nodeId) { + this.selectedNodeId = null; + this.detailOpen = false; + } + this.drawCanvasContent(); + this.refreshDetailPanel(); + } + + private selectNode(nodeId: string) { + this.selectedNodeId = nodeId; + this.detailOpen = true; + const nodeLayer = this.shadow.getElementById('node-layer'); + if (nodeLayer) { + nodeLayer.querySelectorAll('.gc-node').forEach(g => { + g.classList.toggle('selected', g.getAttribute('data-node-id') === nodeId); + }); + } + this.refreshDetailPanel(); + // Re-render nodes to update border highlight + this.drawCanvasContent(); + } + + // ── Wiring ── + + private enterWiring(nodeId: string, portName: string, dir: 'input' | 'output') { + if (dir !== 'output') return; + this.wiringActive = true; + this.wiringSourceNodeId = nodeId; + this.wiringSourcePortName = portName; + const canvas = this.shadow.getElementById('gc-canvas'); + if (canvas) canvas.classList.add('wiring'); + } + + private cancelWiring() { + this.wiringActive = false; + this.wiringSourceNodeId = null; + this.wiringSourcePortName = null; + const canvas = this.shadow.getElementById('gc-canvas'); + if (canvas) canvas.classList.remove('wiring'); + const wireLayer = this.shadow.getElementById('wire-layer'); + if (wireLayer) wireLayer.innerHTML = ''; + } + + private completeWiring(targetNodeId: string, targetPortName: string, targetDir: 'input' | 'output') { + if (!this.wiringSourceNodeId || !this.wiringSourcePortName) { this.cancelWiring(); return; } + if (targetDir !== 'input') { this.cancelWiring(); return; } + if (targetNodeId === this.wiringSourceNodeId) { this.cancelWiring(); return; } + + const exists = this.edges.some(e => + e.fromNode === this.wiringSourceNodeId && e.fromPort === this.wiringSourcePortName && + e.toNode === targetNodeId && e.toPort === targetPortName + ); + if (exists) { this.cancelWiring(); return; } + + const edgeId = `ge-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + this.edges.push({ + id: edgeId, + fromNode: this.wiringSourceNodeId, + fromPort: this.wiringSourcePortName, + toNode: targetNodeId, + toPort: targetPortName, + }); + + this.cancelWiring(); + this.drawCanvasContent(); + } + + private updateWiringTempLine() { + const svg = this.shadow.getElementById('gc-svg') as SVGSVGElement | null; + const wireLayer = this.shadow.getElementById('wire-layer'); + if (!svg || !wireLayer || !this.wiringSourceNodeId || !this.wiringSourcePortName) return; + + const sourceNode = this.nodes.find(n => n.id === this.wiringSourceNodeId); + if (!sourceNode) return; + + const x1 = getPortX(sourceNode, this.wiringSourcePortName!, 'output'); + const y1 = getPortY(sourceNode, this.wiringSourcePortName!, 'output'); + + const rect = svg.getBoundingClientRect(); + const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom; + const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom; + + const d = bezierPath(x1, y1, x2, y2); + wireLayer.innerHTML = ``; + } + + // ── Event listeners ── + + private attachEventListeners() { + const canvas = this.shadow.getElementById('gc-canvas')!; + const svg = this.shadow.getElementById('gc-svg')!; + const palette = this.shadow.getElementById('palette')!; + + // Toolbar buttons + this.shadow.getElementById('btn-zoom-in')?.addEventListener('click', () => { + const rect = svg.getBoundingClientRect(); + this.zoomAt(rect.width / 2, rect.height / 2, 1.2); + }); + this.shadow.getElementById('btn-zoom-out')?.addEventListener('click', () => { + const rect = svg.getBoundingClientRect(); + this.zoomAt(rect.width / 2, rect.height / 2, 0.8); + }); + this.shadow.getElementById('btn-fit')?.addEventListener('click', () => this.fitView()); + this.shadow.getElementById('btn-grid')?.addEventListener('click', () => { + this.showGrid = !this.showGrid; + this.render(); + requestAnimationFrame(() => this.fitView()); + }); + + // Bottom zoom controls + this.shadow.getElementById('zoom-in')?.addEventListener('click', () => { + const rect = svg.getBoundingClientRect(); + this.zoomAt(rect.width / 2, rect.height / 2, 1.2); + }); + this.shadow.getElementById('zoom-out')?.addEventListener('click', () => { + const rect = svg.getBoundingClientRect(); + this.zoomAt(rect.width / 2, rect.height / 2, 0.8); + }); + this.shadow.getElementById('zoom-fit')?.addEventListener('click', () => this.fitView()); + + // Canvas wheel zoom/pan + canvas.addEventListener('wheel', (e: WheelEvent) => { + e.preventDefault(); + if (e.ctrlKey || e.metaKey) { + const rect = svg.getBoundingClientRect(); + const factor = e.deltaY < 0 ? 1.1 : 0.9; + this.zoomAt(e.clientX - rect.left, e.clientY - rect.top, factor); + } else { + this.canvasPanX -= e.deltaX; + this.canvasPanY -= e.deltaY; + this.updateCanvasTransform(); + } + }, { passive: false }); + + // Palette drag + palette.querySelectorAll('.gc-palette__card').forEach(card => { + card.addEventListener('dragstart', (e: Event) => { + const de = e as DragEvent; + const type = (card as HTMLElement).dataset.nodeType!; + de.dataTransfer?.setData('text/plain', type); + }); + }); + + // Canvas drop + canvas.addEventListener('dragover', (e: DragEvent) => { e.preventDefault(); }); + canvas.addEventListener('drop', (e: DragEvent) => { + e.preventDefault(); + const type = e.dataTransfer?.getData('text/plain'); + if (!type) return; + const rect = svg.getBoundingClientRect(); + const x = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom; + const y = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom; + this.addNode(type, x - NODE_WIDTH / 2, y - NODE_HEIGHT / 2); + }); + + // SVG pointer events + svg.addEventListener('pointerdown', (e: PointerEvent) => this.handlePointerDown(e)); + + // Global move/up/key + this._boundPointerMove = (e: PointerEvent) => this.handlePointerMove(e); + this._boundPointerUp = (e: PointerEvent) => this.handlePointerUp(e); + this._boundKeyDown = (e: KeyboardEvent) => this.handleKeyDown(e); + document.addEventListener('pointermove', this._boundPointerMove); + document.addEventListener('pointerup', this._boundPointerUp); + document.addEventListener('keydown', this._boundKeyDown); + + // Detail panel + this.attachDetailListeners(); + } + + private attachDetailListeners() { + this.shadow.getElementById('detail-close')?.addEventListener('click', () => { + this.detailOpen = false; + this.selectedNodeId = null; + const panel = this.shadow.getElementById('detail-panel'); + if (panel) panel.className = 'gc-detail'; + this.drawCanvasContent(); + }); + + this.shadow.getElementById('detail-label')?.addEventListener('input', (e) => { + const node = this.nodes.find(n => n.id === this.selectedNodeId); + if (node) { + node.label = (e.target as HTMLInputElement).value; + this.drawCanvasContent(); + } + }); + + this.shadow.getElementById('detail-delete-node')?.addEventListener('click', () => { + if (this.selectedNodeId) this.deleteNode(this.selectedNodeId); + }); + + const panel = this.shadow.getElementById('detail-panel'); + if (panel) { + panel.querySelectorAll('[data-config-key]').forEach(el => { + const handler = (e: Event) => { + const key = (el as HTMLElement).dataset.configKey!; + const node = this.nodes.find(n => n.id === this.selectedNodeId); + if (!node) return; + let val: any = (e.target as HTMLInputElement).value; + // Type coerce numbers + if ((e.target as HTMLInputElement).type === 'number') { + val = parseFloat(val) || 0; + } + // Coerce booleans from select + if (val === 'true') val = true; + if (val === 'false') val = false; + node.config[key] = val; + this.drawCanvasContent(); + }; + el.addEventListener('input', handler); + el.addEventListener('change', handler); + }); + } + } + + private handlePointerDown(e: PointerEvent) { + const target = e.target as Element; + + // Port click + const portGroup = target.closest('.gc-port-group') as SVGElement | null; + if (portGroup) { + e.stopPropagation(); + const nodeId = portGroup.dataset.nodeId!; + const portName = portGroup.dataset.portName!; + const dir = portGroup.dataset.portDir as 'input' | 'output'; + + if (this.wiringActive) { + this.completeWiring(nodeId, portName, dir); + } else { + this.enterWiring(nodeId, portName, dir); + } + return; + } + + // Edge click — delete + const edgeGroup = target.closest('.gc-edge-group') as SVGElement | null; + if (edgeGroup) { + e.stopPropagation(); + const edgeId = edgeGroup.dataset.edgeId!; + this.edges = this.edges.filter(ed => ed.id !== edgeId); + this.redrawEdges(); + return; + } + + // Node click — select + drag + const nodeGroup = target.closest('.gc-node') as SVGElement | null; + if (nodeGroup) { + e.stopPropagation(); + if (this.wiringActive) { + this.cancelWiring(); + return; + } + const nodeId = nodeGroup.dataset.nodeId!; + this.selectNode(nodeId); + + const node = this.nodes.find(n => n.id === nodeId); + if (node) { + this.draggingNodeId = nodeId; + this.dragStartX = e.clientX; + this.dragStartY = e.clientY; + this.dragNodeStartX = node.position.x; + this.dragNodeStartY = node.position.y; + } + return; + } + + // Canvas — pan or deselect + if (this.wiringActive) { + this.cancelWiring(); + return; + } + + this.isPanning = true; + this.panStartX = e.clientX; + this.panStartY = e.clientY; + this.panStartPanX = this.canvasPanX; + this.panStartPanY = this.canvasPanY; + const canvas = this.shadow.getElementById('gc-canvas'); + if (canvas) canvas.classList.add('grabbing'); + + if (this.selectedNodeId) { + this.selectedNodeId = null; + this.detailOpen = false; + this.drawCanvasContent(); + this.refreshDetailPanel(); + } + } + + private handlePointerMove(e: PointerEvent) { + if (this.wiringActive) { + this.wiringPointerX = e.clientX; + this.wiringPointerY = e.clientY; + this.updateWiringTempLine(); + return; + } + + if (this.draggingNodeId) { + const node = this.nodes.find(n => n.id === this.draggingNodeId); + if (node) { + const dx = (e.clientX - this.dragStartX) / this.canvasZoom; + const dy = (e.clientY - this.dragStartY) / this.canvasZoom; + node.position.x = this.dragNodeStartX + dx; + node.position.y = this.dragNodeStartY + dy; + this.updateNodePosition(node); + this.redrawEdges(); + } + return; + } + + if (this.isPanning) { + this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX); + this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY); + this.updateCanvasTransform(); + } + } + + private handlePointerUp(_e: PointerEvent) { + if (this.draggingNodeId) { + this.draggingNodeId = null; + } + if (this.isPanning) { + this.isPanning = false; + const canvas = this.shadow.getElementById('gc-canvas'); + if (canvas) canvas.classList.remove('grabbing'); + } + } + + private handleKeyDown(e: KeyboardEvent) { + const tag = (e.target as Element)?.tagName; + const isEditing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; + + if (e.key === 'Escape') { + if (this.wiringActive) this.cancelWiring(); + } + if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedNodeId) { + if (isEditing) return; + this.deleteNode(this.selectedNodeId); + } + if (isEditing) return; + if (e.key === 'f' || e.key === 'F') { + this.fitView(); + } + if (e.key === '=' || e.key === '+') { + const svg = this.shadow.getElementById('gc-svg'); + if (svg) { const r = svg.getBoundingClientRect(); this.zoomAt(r.width / 2, r.height / 2, 1.2); } + } + if (e.key === '-') { + const svg = this.shadow.getElementById('gc-svg'); + if (svg) { const r = svg.getBoundingClientRect(); this.zoomAt(r.width / 2, r.height / 2, 0.8); } + } + } +} + +customElements.define('folk-gov-circuit', FolkGovCircuit); diff --git a/modules/rgov/mod.ts b/modules/rgov/mod.ts index 47bdd51..1f8491a 100644 --- a/modules/rgov/mod.ts +++ b/modules/rgov/mod.ts @@ -8,7 +8,6 @@ */ import { Hono } from "hono"; -import { resolve } from "path"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; @@ -17,53 +16,10 @@ import { addShapes, getDocumentData } from "../../server/community-store"; const routes = new Hono(); -// ── Canvas content loader (same approach as rspace module) ── +// ── Module page — renders standalone GovMod circuit canvas ── -const DIST_DIR = resolve(import.meta.dir, "../../dist"); -let canvasCache: { body: string; styles: string; scripts: string } | null = null; - -function extractCanvasContent(html: string) { - const bodyMatch = html.match(/]*>([\s\S]*?)<\/body>/i); - const styleMatches = [...html.matchAll(/]*>([\s\S]*?)<\/style>/gi)]; - const scriptMatches = [...html.matchAll(/]*>[\s\S]*?<\/script>/gi)]; - return { - body: bodyMatch?.[1] || "", - styles: styleMatches.map(m => m[0]).join("\n"), - scripts: scriptMatches.map(m => m[0]).join("\n"), - }; -} - -async function getCanvasContent() { - if (canvasCache) return canvasCache; - - const moduleFile = Bun.file(resolve(DIST_DIR, "canvas-module.html")); - if (await moduleFile.exists()) { - canvasCache = { - body: await moduleFile.text(), - styles: "", - scripts: ``, - }; - return canvasCache; - } - - const fullFile = Bun.file(resolve(DIST_DIR, "canvas.html")); - if (await fullFile.exists()) { - canvasCache = extractCanvasContent(await fullFile.text()); - return canvasCache; - } - - return { - body: `
Canvas loading...
`, - styles: "", - scripts: "", - }; -} - -// ── Module page (within a space) — renders canvas directly ── - -routes.get("/", async (c) => { +routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - const canvas = await getCanvasContent(); return c.html(renderShell({ title: `${space} — rGov | rSpace`, @@ -71,9 +27,8 @@ routes.get("/", async (c) => { spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - body: canvas.body, - styles: canvas.styles, - scripts: canvas.scripts, + body: ``, + scripts: ``, })); }); diff --git a/vite.config.ts b/vite.config.ts index 97d5608..9c7d58e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1394,6 +1394,27 @@ export default defineConfig({ } } + // ── Build rGov circuit canvas component ── + mkdirSync(resolve(__dirname, "dist/modules/rgov"), { recursive: true }); + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/rgov/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rgov"), + lib: { + entry: resolve(__dirname, "modules/rgov/components/folk-gov-circuit.ts"), + formats: ["es"], + fileName: () => "folk-gov-circuit.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-gov-circuit.js", + }, + }, + }, + }); + // ── Generate content hashes for cache-busting ── const { readdirSync, readFileSync, writeFileSync, statSync: statSync2 } = await import("node:fs"); const { createHash } = await import("node:crypto");