From 5f45014226c132b487710793bdc4da0555469bca Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 12:06:44 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(rtime):=20unified=20Canvas=20view=20?= =?UTF-8?q?=E2=80=94=20merge=20Pool=20+=20Weave=20with=20pan/zoom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 3-tab layout (Pool/Weave/Collaborate) with 2-tab Canvas + Collaborate. Canvas tab has collapsible left pool panel with orbs alongside infinite SVG canvas with pan/zoom (ctrl+wheel zoom, wheel pan, space+drag, touch pinch). - Long-press orb in pool → drag onto canvas to create commitment node - Drop on matching task port auto-creates wire connection - Gold glow highlights unfulfilled task ports matching dragged skill - "Frame as Tasks" button on solver results creates task nodes with dashed intent frame on canvas - Add intentFrameId to Task schema for solver-to-task linkage - Zoom controls overlay (+/−/reset) - Light/dark theme support for all new elements Co-Authored-By: Claude Opus 4.6 --- modules/rtime/components/folk-timebank-app.ts | 796 ++++++++++++++---- modules/rtime/mod.ts | 3 +- modules/rtime/schemas.ts | 1 + 3 files changed, 636 insertions(+), 164 deletions(-) diff --git a/modules/rtime/components/folk-timebank-app.ts b/modules/rtime/components/folk-timebank-app.ts index 38603f9..eb7a4b5 100644 --- a/modules/rtime/components/folk-timebank-app.ts +++ b/modules/rtime/components/folk-timebank-app.ts @@ -245,9 +245,9 @@ function svgText(txt: string, x: number, y: number, size: number, color: string, class FolkTimebankApp extends HTMLElement { private shadow: ShadowRoot; private space = 'demo'; - private currentView: 'pool' | 'weave' | 'collaborate' = 'pool'; + private currentView: 'canvas' | 'collaborate' = 'canvas'; - // Pool state + // Pool panel state private canvas!: HTMLCanvasElement; private ctx!: CanvasRenderingContext2D; private orbs: Orb[] = []; @@ -263,8 +263,25 @@ class FolkTimebankApp extends HTMLElement { private dpr = 1; private poolPointerId: number | null = null; private poolPointerStart: { x: number; y: number; cx: number; cy: number } | null = null; + private poolPanelCollapsed = false; - // Weave state + // Pan/zoom state + private panX = 0; + private panY = 0; + private scale = 1; + private canvasGroup!: SVGGElement; + private isPanning = false; + private panStart = { x: 0, y: 0, panX: 0, panY: 0 }; + private spaceHeld = false; + private intentFramesLayer!: SVGGElement; + + // Orb drag-to-canvas state + private draggingOrb: Orb | null = null; + private draggingSkill: string | null = null; + private orbDragGhost: HTMLElement | null = null; + private orbDragTimer: ReturnType | null = null; + + // SVG canvas state private svgEl!: SVGSVGElement; private nodesLayer!: SVGGElement; private connectionsLayer!: SVGGElement; @@ -306,18 +323,22 @@ class FolkTimebankApp extends HTMLElement { attributeChangedCallback(name: string, _old: string, val: string) { if (name === 'space') this.space = val; - if (name === 'view' && (val === 'pool' || val === 'weave' || val === 'collaborate')) this.currentView = val; + if (name === 'view' && (val === 'canvas' || val === 'collaborate')) this.currentView = val; } connectedCallback() { this.space = this.getAttribute('space') || 'demo'; - this.currentView = (this.getAttribute('view') as any) || 'pool'; + const rawView = this.getAttribute('view'); + // Map legacy view names + if (rawView === 'pool' || rawView === 'weave' || rawView === 'canvas') this.currentView = 'canvas'; + else if (rawView === 'collaborate') this.currentView = 'collaborate'; + else this.currentView = 'canvas'; this.dpr = window.devicePixelRatio || 1; this._theme = (localStorage.getItem('rtime-theme') as 'dark' | 'light') || 'dark'; this.render(); this.applyTheme(); - this.setupPool(); - this.setupWeave(); + this.setupPoolPanel(); + this.setupCanvas(); this.setupCollaborate(); this.fetchData(); } @@ -399,9 +420,10 @@ class FolkTimebankApp extends HTMLElement { // Auto-place first task if canvas is empty if (this.weaveNodes.length === 0 && this.tasks.length > 0) { - const svgRect = this.svgEl?.getBoundingClientRect(); - const x = svgRect ? svgRect.width * 0.55 : 400; - const y = svgRect ? svgRect.height * 0.2 : 80; + const wrap = this.shadow.getElementById('canvasWrap'); + const wrapRect = wrap?.getBoundingClientRect(); + const x = wrapRect ? wrapRect.width * 0.4 : 400; + const y = wrapRect ? wrapRect.height * 0.2 : 80; this.weaveNodes.push(this.mkTaskNode(this.tasks[0], x - TASK_W / 2, y)); this.renderAll(); this.rebuildSidebar(); @@ -412,8 +434,7 @@ class FolkTimebankApp extends HTMLElement { this.shadow.innerHTML = `
-
Commitment Pool
-
Weaving Dashboard
+
Canvas
Collaborate
@@ -424,26 +445,30 @@ class FolkTimebankApp extends HTMLElement {
-
- -
-
-
-
+
+ +
+
+ Commitments +
-
-
-
-
- -
- `; @@ -1945,6 +2267,108 @@ class FolkTimebankApp extends HTMLElement { } catch { /* ignore */ } }); }); + + // Frame as Tasks buttons + container.querySelectorAll('.solver-frame-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const resultId = (btn as HTMLElement).dataset.resultId; + const result = this.solverResults.find(r => r.id === resultId); + if (!result) return; + await this.frameResultAsTasks(result); + }); + }); + } + + /** Create tasks from a solver result and lay them out on the canvas with a frame. */ + private async frameResultAsTasks(result: any) { + const skills = result.skills || []; + const skillPairs = skills.length > 0 ? skills : ['facilitation']; + + // Group skills into task(s) — one task per 2 skills, or one per unique group + const taskDefs: { name: string; needs: Record }[] = []; + if (skillPairs.length <= 2) { + taskDefs.push({ + name: `Collaboration: ${skillPairs.map((s: string) => SKILL_LABELS[s] || s).join(' + ')}`, + needs: Object.fromEntries(skillPairs.map((s: string) => [s, Math.ceil((result.totalHours || 4) / skillPairs.length)])), + }); + } else { + for (let i = 0; i < skillPairs.length; i += 2) { + const group = skillPairs.slice(i, i + 2); + taskDefs.push({ + name: `${group.map((s: string) => SKILL_LABELS[s] || s).join(' + ')} Work`, + needs: Object.fromEntries(group.map((s: string) => [s, Math.ceil((result.totalHours || 4) / skillPairs.length)])), + }); + } + } + + // POST tasks to server and collect created tasks + const createdTasks: TaskData[] = []; + for (const def of taskDefs) { + try { + const resp = await fetch(`${this.getApiBase()}/api/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, + body: JSON.stringify({ name: def.name, description: `From solver result ${result.id}`, needs: def.needs, intentFrameId: result.id }), + }); + if (resp.ok) { + const task = await resp.json(); + createdTasks.push({ ...task, fulfilled: {} }); + this.tasks.push(task); + } + } catch { /* offline */ } + } + + if (createdTasks.length === 0) return; + + // Auto-layout: horizontal row on canvas + const wrap = this.shadow.getElementById('canvasWrap'); + const wrapRect = wrap?.getBoundingClientRect(); + const startX = wrapRect ? wrapRect.width * 0.15 : 100; + const startY = wrapRect ? wrapRect.height * 0.3 : 150; + const gap = TASK_W + 40; + + createdTasks.forEach((t, i) => { + const x = startX + i * gap; + const y = startY; + this.weaveNodes.push(this.mkTaskNode(t, x, y)); + }); + + // Draw dashed frame around the group in intentFramesLayer + const padding = 30; + const frameX = startX - padding; + const frameY = startY - padding - 20; + const frameW = createdTasks.length * gap - 40 + TASK_W + padding; + const frameH = 200 + padding * 2; + + const frame = ns('rect'); + frame.setAttribute('x', String(frameX)); + frame.setAttribute('y', String(frameY)); + frame.setAttribute('width', String(frameW)); + frame.setAttribute('height', String(frameH)); + frame.setAttribute('rx', '12'); + frame.setAttribute('fill', 'none'); + frame.setAttribute('stroke', '#fbbf2466'); + frame.setAttribute('stroke-width', '2'); + frame.setAttribute('stroke-dasharray', '8 4'); + frame.setAttribute('class', 'intent-frame'); + this.intentFramesLayer.appendChild(frame); + + // Frame label + const label = svgText( + `Solver: ${(result.skills || []).map((s: string) => SKILL_LABELS[s] || s).join(', ')}`, + frameX + 12, frameY + 16, 11, '#fbbf24', '500' + ); + label.setAttribute('class', 'intent-frame-label'); + this.intentFramesLayer.appendChild(label); + + this.renderAll(); + this.rebuildSidebar(); + + // Switch to canvas view + this.currentView = 'canvas'; + this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === 'canvas')); + this.shadow.getElementById('canvas-view')!.style.display = 'flex'; + this.shadow.getElementById('collaborate-view')!.style.display = 'none'; } private renderSkillPrices() { @@ -2019,16 +2443,59 @@ const CSS_TEXT = ` .main { flex: 1; position: relative; overflow: hidden; } -#pool-view { width: 100%; height: 100%; position: absolute; top: 0; left: 0; } -#pool-canvas { width: 100%; height: 100%; display: block; cursor: default; touch-action: none; } +/* Unified canvas view: pool panel + SVG canvas side by side */ +#canvas-view { + display: flex; + width: 100%; height: 100%; + position: absolute; top: 0; left: 0; + overflow: hidden; +} + +/* Pool panel (left side) */ +.pool-panel { + width: 260px; + flex-shrink: 0; + background: #1e293b; + border-right: 1px solid #334155; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + transition: width 0.2s ease; +} +.pool-panel.collapsed { width: 40px; } +.pool-panel.collapsed #pool-canvas, +.pool-panel.collapsed .pool-detail, +.pool-panel.collapsed .pool-panel-sidebar, +.pool-panel.collapsed .add-btn { display: none; } +.pool-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + font-size: 0.82rem; + font-weight: 600; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.04em; + border-bottom: 1px solid #334155; + flex-shrink: 0; +} +.pool-panel-header button { + background: none; border: 1px solid #475569; border-radius: 0.25rem; + color: #94a3b8; font-size: 0.85rem; cursor: pointer; padding: 0.1rem 0.4rem; + line-height: 1; transition: border-color 0.15s; +} +.pool-panel-header button:hover { border-color: #8b5cf6; color: #e2e8f0; } +#pool-canvas { flex: 1; min-height: 0; display: block; cursor: default; touch-action: none; } .pool-detail { position: absolute; background: #1e293b; border-radius: 0.75rem; box-shadow: 0 8px 32px rgba(0,0,0,0.4); - padding: 1.25rem; - min-width: 220px; + padding: 1rem; + min-width: 200px; pointer-events: none; opacity: 0; transform: scale(0.9) translateY(8px); @@ -2036,108 +2503,90 @@ const CSS_TEXT = ` z-index: 50; } .pool-detail.visible { opacity: 1; transform: scale(1) translateY(0); pointer-events: auto; } -.pool-detail-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } +.pool-detail-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; } .pool-detail-dot { width: 10px; height: 10px; border-radius: 50%; } -.pool-detail-name { font-weight: 600; font-size: 0.95rem; color: #f1f5f9; } -.pool-detail-skill { font-size: 0.82rem; color: #94a3b8; margin-bottom: 0.3rem; } -.pool-detail-hours { font-size: 0.85rem; font-weight: 600; color: #8b5cf6; } -.pool-detail-desc { font-size: 0.8rem; color: #94a3b8; margin-top: 0.35rem; line-height: 1.4; } +.pool-detail-name { font-weight: 600; font-size: 0.9rem; color: #f1f5f9; } +.pool-detail-skill { font-size: 0.78rem; color: #94a3b8; margin-bottom: 0.2rem; } +.pool-detail-hours { font-size: 0.82rem; font-weight: 600; color: #8b5cf6; } +.pool-detail-desc { font-size: 0.75rem; color: #94a3b8; margin-top: 0.25rem; line-height: 1.4; } + +.pool-panel-sidebar { flex-shrink: 0; overflow-y: auto; max-height: 200px; border-top: 1px solid #334155; } .add-btn { - position: absolute; - bottom: 1.5rem; - right: 1.5rem; - padding: 0.65rem 1.25rem; + padding: 0.5rem 0.75rem; + margin: 0.5rem; background: linear-gradient(135deg, #8b5cf6, #ec4899); color: #fff; border: none; border-radius: 0.5rem; - font-size: 0.9rem; + font-size: 0.82rem; font-weight: 500; cursor: pointer; box-shadow: 0 4px 16px rgba(139,92,246,0.3); transition: all 0.2s; - z-index: 20; + flex-shrink: 0; } .add-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(139,92,246,0.4); } -/* Weaving */ -#weave-view { - width: 100%; height: 100%; - position: absolute; top: 0; left: 0; - flex-direction: row; -} - -.sidebar { - width: 260px; - background: #1e293b; - border-right: 1px solid #334155; - display: flex; - flex-direction: column; - flex-shrink: 0; - overflow: hidden; -} -.sidebar-header { - padding: 0.75rem 1rem; - font-size: 0.85rem; - font-weight: 600; - color: #94a3b8; - text-transform: uppercase; - letter-spacing: 0.04em; - border-bottom: 1px solid #334155; -} -.sidebar-items { flex: 1; overflow-y: auto; padding: 0.5rem; } -.sidebar-item { - display: flex; - align-items: center; - gap: 0.6rem; - padding: 0.6rem 0.75rem; - border-radius: 0.5rem; - cursor: grab; - transition: background 0.15s; - margin-bottom: 0.25rem; - touch-action: none; -} -.sidebar-item:hover { background: #334155; } -.sidebar-item.used { opacity: 0.35; pointer-events: none; } -.sidebar-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } .sidebar-item-info { flex: 1; min-width: 0; } .sidebar-item-name { font-size: 0.82rem; font-weight: 600; color: #f1f5f9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sidebar-item-meta { font-size: 0.72rem; color: #64748b; } .sidebar-section { - padding: 0.75rem 1rem; - font-size: 0.78rem; + padding: 0.5rem 0.75rem; + font-size: 0.72rem; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; - border-top: 1px solid #334155; - margin-top: 0.25rem; } .sidebar-task { display: flex; align-items: center; - gap: 0.6rem; - padding: 0.6rem 0.75rem; - border-radius: 0.5rem; + gap: 0.5rem; + padding: 0.45rem 0.65rem; + border-radius: 0.375rem; cursor: grab; transition: background 0.15s; - margin: 0 0.5rem 0.25rem; + margin: 0 0.25rem 0.2rem; touch-action: none; } .sidebar-task:hover { background: #334155; } .sidebar-task-icon { - width: 24px; height: 24px; - border-radius: 0.375rem; + width: 22px; height: 22px; + border-radius: 0.25rem; background: linear-gradient(135deg, #8b5cf6, #ec4899); display: flex; align-items: center; justify-content: center; - color: #fff; font-size: 0.7rem; flex-shrink: 0; + color: #fff; font-size: 0.65rem; flex-shrink: 0; } -.canvas-wrap { flex: 1; position: relative; overflow: hidden; background: #0f172a; } +/* SVG canvas area */ +.canvas-wrap { flex: 1; position: relative; overflow: hidden; background: #0f172a; touch-action: none; } #weave-svg { width: 100%; height: 100%; display: block; touch-action: none; } +/* Zoom controls */ +.zoom-controls { + position: absolute; + bottom: 1rem; + right: 1rem; + z-index: 50; + display: flex; + flex-direction: column; + gap: 0.25rem; +} +.zoom-controls button { + width: 32px; height: 32px; + border-radius: 0.375rem; + border: 1px solid #475569; + background: #1e293b; + color: #e2e8f0; + font-size: 1rem; + cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: all 0.15s; +} +.zoom-controls button:hover { border-color: #8b5cf6; background: #334155; } + .node-group { cursor: grab; } .node-group:active { cursor: grabbing; } .node-rect { fill: #1e293b; stroke: #334155; stroke-width: 1; transition: stroke 0.15s; } @@ -2149,6 +2598,11 @@ const CSS_TEXT = ` .temp-connection { fill: none; stroke: #8b5cf6; stroke-width: 2; stroke-dasharray: 6 4; pointer-events: none; opacity: 0.6; } .task-node .node-rect { stroke: #8b5cf6; stroke-width: 1.5; } .task-node.ready .node-rect { stroke: #10b981; stroke-width: 2; } +.task-node.skill-match .node-rect { stroke: #fbbf24; stroke-width: 2.5; filter: url(#glowGold); } + +/* Intent frames */ +.intent-frame { pointer-events: none; } +.intent-frame-label { pointer-events: none; } .exec-btn-rect { cursor: pointer; transition: opacity 0.15s; } .exec-btn-rect:hover { opacity: 0.85; } @@ -2555,6 +3009,18 @@ const CSS_TEXT = ` background: #334155; color: #94a3b8; } +.solver-frame-btn { + background: linear-gradient(135deg, #f59e0b, #fbbf24); + color: #1e293b; + padding: 0.3rem 0.75rem; + border: none; + border-radius: 0.375rem; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} +.solver-frame-btn:hover { opacity: 0.85; } /* Skill prices */ .collab-prices-section h3 { @@ -2598,13 +3064,18 @@ const CSS_TEXT = ` :host([data-theme="light"]) .stats-bar { background: linear-gradient(135deg, #f0ecff 0%, #fdf2f8 100%); border-bottom-color: #e2e8f0; } :host([data-theme="light"]) .stat { color: #64748b; } :host([data-theme="light"]) .stat-value { color: #1e293b; } -:host([data-theme="light"]) .sidebar { background: #fff; border-right-color: #e2e8f0; } -:host([data-theme="light"]) .sidebar-header { color: #64748b; border-bottom-color: #e2e8f0; } -:host([data-theme="light"]) .sidebar-item:hover { background: #f1f5f9; } +:host([data-theme="light"]) .pool-panel { background: #fff; border-right-color: #e2e8f0; } +:host([data-theme="light"]) .pool-panel-header { color: #64748b; border-bottom-color: #e2e8f0; } +:host([data-theme="light"]) .pool-panel-header button { border-color: #e2e8f0; color: #64748b; } +:host([data-theme="light"]) .sidebar-task:hover { background: #f1f5f9; } :host([data-theme="light"]) .sidebar-item-name { color: #1e293b; } :host([data-theme="light"]) .canvas-wrap { background: #f8fafc; } :host([data-theme="light"]) .node-rect { fill: #fff; stroke: #e2e8f0; } :host([data-theme="light"]) .theme-toggle { border-color: #e2e8f0; color: #64748b; } +:host([data-theme="light"]) .zoom-controls button { background: #fff; border-color: #e2e8f0; color: #1e293b; } +:host([data-theme="light"]) .zoom-controls button:hover { border-color: #8b5cf6; background: #f1f5f9; } +:host([data-theme="light"]) .pool-detail { background: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.12); } +:host([data-theme="light"]) .pool-detail-name { color: #1e293b; } /* Hex hover stroke */ .hex-hover-stroke { transition: stroke-width 0.15s; } @@ -2665,12 +3136,13 @@ const CSS_TEXT = ` .exec-step-checklist input[type="checkbox"] { accent-color: #8b5cf6; } @media (max-width: 768px) { - .sidebar { width: 200px; } + .pool-panel { width: 200px; } .exec-panel { width: 95vw; } .task-edit-panel { width: 95vw; } } @media (max-width: 640px) { - .sidebar { width: 180px; } + .pool-panel { width: 180px; } + .pool-panel.collapsed { width: 36px; } } `; diff --git a/modules/rtime/mod.ts b/modules/rtime/mod.ts index 018b9c6..1da8973 100644 --- a/modules/rtime/mod.ts +++ b/modules/rtime/mod.ts @@ -467,8 +467,7 @@ export const timeModule: RSpaceModule = { }, ], outputPaths: [ - { path: "commitments", name: "Commitments", icon: "🧺", description: "Community hour pledges" }, - { path: "weave", name: "Weave", icon: "🧶", description: "Task weaving dashboard" }, + { path: "canvas", name: "Canvas", icon: "🧺", description: "Unified commitment pool & task weaving canvas" }, { path: "collaborate", name: "Collaborate", icon: "🤝", description: "Intent-routed collaboration matching" }, ], onboardingActions: [ diff --git a/modules/rtime/schemas.ts b/modules/rtime/schemas.ts index 79d6237..30b756d 100644 --- a/modules/rtime/schemas.ts +++ b/modules/rtime/schemas.ts @@ -52,6 +52,7 @@ export interface Task { needs: Record; // skill → hours needed links: { label: string; url: string }[]; notes: string; + intentFrameId?: string; // links task to solver result that spawned it } export interface Connection { From f375eb1b43385b6e64b093e8496ea5c7f806d4fc Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 13:01:08 -0700 Subject: [PATCH 2/3] feat(rgov): add rGov governance decision circuit module Implements 5 new FolkShape web components for visual governance: - folk-gov-binary: Yes/No signoff gates - folk-gov-threshold: Numeric progress gates with contributions - folk-gov-knob: SVG rotary parameter knobs with temporal viscosity - folk-gov-project: Circuit aggregators (walks arrow graph backward) - folk-gov-amendment: Modification proposals with approval voting Also adds "satisfied" gate condition to folk-arrow, 5 AI canvas tools, module registration, and all canvas.html wiring. Co-Authored-By: Claude Opus 4.6 --- lib/canvas-tools.ts | 127 +++++++ lib/folk-arrow.ts | 4 +- lib/folk-gov-amendment.ts | 710 ++++++++++++++++++++++++++++++++++++++ lib/folk-gov-binary.ts | 345 ++++++++++++++++++ lib/folk-gov-knob.ts | 518 +++++++++++++++++++++++++++ lib/folk-gov-project.ts | 456 ++++++++++++++++++++++++ lib/folk-gov-threshold.ts | 478 +++++++++++++++++++++++++ lib/index.ts | 7 + modules/rgov/mod.ts | 89 +++++ server/index.ts | 2 + website/canvas.html | 15 + 11 files changed, 2750 insertions(+), 1 deletion(-) create mode 100644 lib/folk-gov-amendment.ts create mode 100644 lib/folk-gov-binary.ts create mode 100644 lib/folk-gov-knob.ts create mode 100644 lib/folk-gov-project.ts create mode 100644 lib/folk-gov-threshold.ts create mode 100644 modules/rgov/mod.ts diff --git a/lib/canvas-tools.ts b/lib/canvas-tools.ts index f015b11..45c4261 100644 --- a/lib/canvas-tools.ts +++ b/lib/canvas-tools.ts @@ -489,6 +489,133 @@ registry.push({ actionLabel: (args) => `Opened design agent${args.brief ? `: ${args.brief.slice(0, 50)}` : ""}`, }); +// ── rGov Governance Circuit Tools ── +registry.push( + { + declaration: { + name: "create_binary_gate", + description: "Create a Yes/No signoff gate on the canvas. Use when a decision requires someone's explicit approval or sign-off.", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Title for the signoff gate (e.g. 'Proprietor Approval')" }, + assignee: { type: "string", description: "Who must sign off (leave empty for 'anyone')" }, + }, + required: ["title"], + }, + }, + tagName: "folk-gov-binary", + moduleId: "rgov", + buildProps: (args) => ({ + title: args.title, + ...(args.assignee ? { assignee: args.assignee } : {}), + }), + actionLabel: (args) => `Created binary gate: ${args.title}`, + }, + { + declaration: { + name: "create_threshold", + description: "Create a numeric threshold gate on the canvas. Use when a decision requires accumulating a target amount (hours, dollars, signatures, etc.).", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Title for the threshold (e.g. 'Capital Required')" }, + target: { type: "number", description: "Target value to reach" }, + unit: { type: "string", description: "Unit of measurement (e.g. '$', 'hours', 'signatures')" }, + }, + required: ["title", "target"], + }, + }, + tagName: "folk-gov-threshold", + moduleId: "rgov", + buildProps: (args) => ({ + title: args.title, + target: args.target, + ...(args.unit ? { unit: args.unit } : {}), + }), + actionLabel: (args) => `Created threshold: ${args.title} (${args.target} ${args.unit || ""})`, + }, + { + declaration: { + name: "create_gov_knob", + description: "Create an adjustable parameter knob on the canvas. Use when a governance parameter needs to be tunable (e.g. quorum percentage, budget cap).", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Parameter name (e.g. 'Quorum %')" }, + min: { type: "number", description: "Minimum value" }, + max: { type: "number", description: "Maximum value" }, + value: { type: "number", description: "Initial value" }, + unit: { type: "string", description: "Unit label (e.g. '%', '$', 'hours')" }, + cooldown: { type: "number", description: "Cooldown in seconds before value propagates (0 for instant)" }, + }, + required: ["title"], + }, + }, + tagName: "folk-gov-knob", + moduleId: "rgov", + buildProps: (args) => ({ + title: args.title, + ...(args.min != null ? { min: args.min } : {}), + ...(args.max != null ? { max: args.max } : {}), + ...(args.value != null ? { value: args.value } : {}), + ...(args.unit ? { unit: args.unit } : {}), + ...(args.cooldown != null ? { cooldown: args.cooldown } : {}), + }), + actionLabel: (args) => `Created knob: ${args.title}`, + }, + { + declaration: { + name: "create_gov_project", + description: "Create a governance project aggregator on the canvas. It automatically tracks all upstream gates wired to it and shows overall completion progress.", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Project title (e.g. 'Build a Climbing Wall')" }, + description: { type: "string", description: "Project description" }, + status: { type: "string", description: "Initial status", enum: ["draft", "active", "completed", "archived"] }, + }, + required: ["title"], + }, + }, + tagName: "folk-gov-project", + moduleId: "rgov", + buildProps: (args) => ({ + title: args.title, + ...(args.description ? { description: args.description } : {}), + ...(args.status ? { status: args.status } : {}), + }), + actionLabel: (args) => `Created project: ${args.title}`, + }, + { + declaration: { + name: "create_amendment", + description: "Create a governance amendment proposal on the canvas. An amendment proposes replacing one gate with another (e.g. converting a dollar threshold into a binary checkbox).", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Amendment title" }, + targetShapeId: { type: "string", description: "ID of the shape to modify" }, + replacementType: { type: "string", description: "Type of replacement shape (e.g. 'folk-gov-binary')" }, + approvalMode: { type: "string", description: "How approval works", enum: ["single", "majority", "unanimous"] }, + description: { type: "string", description: "Description of what the amendment changes" }, + }, + required: ["title"], + }, + }, + tagName: "folk-gov-amendment", + moduleId: "rgov", + buildProps: (args) => ({ + title: args.title, + ...(args.targetShapeId ? { targetShapeId: args.targetShapeId } : {}), + ...(args.replacementType ? { replacementType: args.replacementType } : {}), + ...(args.approvalMode ? { approvalMode: args.approvalMode } : {}), + ...(args.description ? { description: args.description } : {}), + }), + actionLabel: (args) => `Created amendment: ${args.title}`, + }, +); + export const CANVAS_TOOLS: CanvasToolDefinition[] = [...registry]; export const CANVAS_TOOL_DECLARATIONS = CANVAS_TOOLS.map((t) => t.declaration); diff --git a/lib/folk-arrow.ts b/lib/folk-arrow.ts index 74106b7..fa974fb 100644 --- a/lib/folk-arrow.ts +++ b/lib/folk-arrow.ts @@ -138,7 +138,7 @@ export type ArrowStyle = "smooth" | "straight" | "curved" | "sketchy"; export interface ArrowGate { shapeId: string; // governance shape ID portName: string; // port to watch (e.g. "decision-out") - condition: "truthy" | "passed" | "threshold"; + condition: "truthy" | "passed" | "threshold" | "satisfied"; threshold?: number; } @@ -464,6 +464,8 @@ export class FolkArrow extends FolkElement { const v = value as any; const num = typeof v === "number" ? v : (v?.margin ?? v?.score ?? 0); this.#gateOpen = num >= (this.#gate.threshold ?? 0.5); + } else if (this.#gate.condition === "satisfied") { + this.#gateOpen = (value as any)?.satisfied === true; } if (wasOpen !== this.#gateOpen) this.#updateArrow(); diff --git a/lib/folk-gov-amendment.ts b/lib/folk-gov-amendment.ts new file mode 100644 index 0000000..9fe1eb7 --- /dev/null +++ b/lib/folk-gov-amendment.ts @@ -0,0 +1,710 @@ +/** + * folk-gov-amendment — Circuit Modification Proposal + * + * References a target shape to modify, contains proposed replacement data. + * Built-in approval mechanism (majority/unanimous/single) with inline voter list. + * Shows a "Before → After" diff view. On approval, dispatches gov-amendment-apply + * event to replace the target shape while preserving its ID. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; +import type { PortDescriptor } from "./data-types"; + +const HEADER_COLOR = "#be185d"; + +type ApprovalMode = "single" | "majority" | "unanimous"; + +interface Vote { + voter: string; + approve: boolean; + timestamp: number; +} + +const styles = css` + :host { + background: var(--rs-bg-surface, #1e293b); + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); + min-width: 280px; + min-height: 200px; + overflow: hidden; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: ${HEADER_COLOR}; + color: white; + font-size: 12px; + font-weight: 600; + cursor: move; + border-radius: 10px 10px 0 0; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .body { + display: flex; + flex-direction: column; + padding: 12px; + gap: 8px; + overflow-y: auto; + max-height: calc(100% - 36px); + } + + .title-input { + background: transparent; + border: none; + color: var(--rs-text-primary, #e2e8f0); + font-size: 13px; + font-weight: 600; + width: 100%; + outline: none; + } + + .title-input::placeholder { + color: var(--rs-text-muted, #64748b); + } + + .field-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + } + + .field-input { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 11px; + padding: 3px 6px; + outline: none; + flex: 1; + } + + .mode-select { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 11px; + padding: 3px 6px; + outline: none; + } + + .diff-section { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 4px; + } + + .diff-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--rs-text-muted, #64748b); + } + + .diff-box { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 4px; + padding: 6px 8px; + font-size: 10px; + font-family: monospace; + color: var(--rs-text-secondary, #94a3b8); + max-height: 60px; + overflow-y: auto; + white-space: pre-wrap; + } + + .diff-box.before { + border-left: 3px solid #ef4444; + } + + .diff-box.after { + border-left: 3px solid #22c55e; + } + + .diff-arrow { + text-align: center; + font-size: 14px; + color: var(--rs-text-muted, #475569); + } + + .desc-textarea { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 4px; + color: var(--rs-text-secondary, #cbd5e1); + font-size: 11px; + padding: 6px 8px; + outline: none; + resize: none; + min-height: 36px; + } + + .desc-textarea::placeholder { + color: var(--rs-text-muted, #475569); + } + + .voters-section { + display: flex; + flex-direction: column; + gap: 4px; + } + + .voter-row { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + padding: 3px 6px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.03); + } + + .voter-name { + color: var(--rs-text-primary, #e2e8f0); + } + + .vote-badge { + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 3px; + } + + .vote-badge.approve { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; + } + + .vote-badge.reject { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; + } + + .vote-actions { + display: flex; + gap: 4px; + margin-top: 4px; + } + + .vote-name-input { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 11px; + padding: 3px 6px; + outline: none; + flex: 1; + } + + .vote-btn { + border: none; + border-radius: 4px; + padding: 3px 8px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + } + + .vote-btn.approve { + background: #22c55e; + color: white; + } + + .vote-btn.reject { + background: #ef4444; + color: white; + } + + .vote-btn:hover { + opacity: 0.85; + } + + .apply-btn { + background: ${HEADER_COLOR}; + border: none; + color: white; + border-radius: 6px; + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + text-align: center; + margin-top: 4px; + } + + .apply-btn:hover { + opacity: 0.85; + } + + .apply-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .status-badge { + display: inline-block; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 2px 8px; + border-radius: 4px; + text-align: center; + } + + .status-badge.pending { + background: rgba(245, 158, 11, 0.2); + color: #f59e0b; + } + + .status-badge.approved { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; + } + + .status-badge.rejected { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; + } + + .status-badge.applied { + background: rgba(29, 78, 216, 0.2); + color: #60a5fa; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-gov-amendment": FolkGovAmendment; + } +} + +export class FolkGovAmendment extends FolkShape { + static override tagName = "folk-gov-amendment"; + + static override portDescriptors: PortDescriptor[] = [ + { name: "approval-in", type: "json", direction: "input" }, + { name: "amendment-out", type: "json", direction: "output" }, + { name: "gate-out", type: "json", direction: "output" }, + ]; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n"); + const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #title = "Amendment"; + #targetShapeId = ""; + #replacementType = "folk-gov-binary"; // what to replace with + #replacementData: Record = {}; + #approvalMode: ApprovalMode = "single"; + #votes: Vote[] = []; + #amendmentStatus: "pending" | "approved" | "rejected" | "applied" = "pending"; + #description = ""; + + // DOM refs + #titleEl!: HTMLInputElement; + #targetEl!: HTMLInputElement; + #replTypeEl!: HTMLInputElement; + #modeEl!: HTMLSelectElement; + #descEl!: HTMLTextAreaElement; + #votersEl!: HTMLElement; + #statusEl!: HTMLElement; + #applyBtn!: HTMLButtonElement; + #beforeBox!: HTMLElement; + #voteNameEl!: HTMLInputElement; + + get title() { return this.#title; } + set title(v: string) { + this.#title = v; + if (this.#titleEl) this.#titleEl.value = v; + } + + get targetShapeId() { return this.#targetShapeId; } + set targetShapeId(v: string) { + this.#targetShapeId = v; + if (this.#targetEl) this.#targetEl.value = v; + this.#updateDiff(); + } + + get replacementType() { return this.#replacementType; } + set replacementType(v: string) { + this.#replacementType = v; + if (this.#replTypeEl) this.#replTypeEl.value = v; + } + + get replacementData() { return this.#replacementData; } + set replacementData(v: Record) { this.#replacementData = v; } + + get approvalMode(): ApprovalMode { return this.#approvalMode; } + set approvalMode(v: ApprovalMode) { + this.#approvalMode = v; + if (this.#modeEl) this.#modeEl.value = v; + } + + get votes() { return [...this.#votes]; } + set votes(v: Vote[]) { + this.#votes = v; + this.#renderVoters(); + this.#checkApproval(); + } + + get amendmentStatus() { return this.#amendmentStatus; } + set amendmentStatus(v: "pending" | "approved" | "rejected" | "applied") { + this.#amendmentStatus = v; + this.#updateStatusBadge(); + } + + get description() { return this.#description; } + set description(v: string) { + this.#description = v; + if (this.#descEl) this.#descEl.value = v; + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + this.initPorts(); + + const wrapper = document.createElement("div"); + wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; + wrapper.innerHTML = html` +
+ 📝 Amendment + + + +
+
+ +
+ Target: + +
+
+ Replace with: + +
+
+ Approval: + +
+ +
+ Before +
+
+ After +
+
+
+
+ + + +
+ PENDING + +
+ `; + + const slot = root.querySelector("slot"); + const container = slot?.parentElement as HTMLElement; + if (container) container.replaceWith(wrapper); + + // Cache refs + this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; + this.#targetEl = wrapper.querySelector(".target-id") as HTMLInputElement; + this.#replTypeEl = wrapper.querySelector(".repl-type") as HTMLInputElement; + this.#modeEl = wrapper.querySelector(".mode-select") as HTMLSelectElement; + this.#descEl = wrapper.querySelector(".desc-textarea") as HTMLTextAreaElement; + this.#votersEl = wrapper.querySelector(".voters-section") as HTMLElement; + this.#statusEl = wrapper.querySelector(".status-badge") as HTMLElement; + this.#applyBtn = wrapper.querySelector(".apply-btn") as HTMLButtonElement; + this.#beforeBox = wrapper.querySelector(".diff-box.before") as HTMLElement; + this.#voteNameEl = wrapper.querySelector(".vote-name-input") as HTMLInputElement; + const afterBox = wrapper.querySelector(".diff-box.after") as HTMLElement; + + // Set initial values + this.#titleEl.value = this.#title; + this.#targetEl.value = this.#targetShapeId; + this.#replTypeEl.value = this.#replacementType; + this.#modeEl.value = this.#approvalMode; + this.#descEl.value = this.#description; + afterBox.textContent = this.#replacementType; + this.#updateDiff(); + this.#renderVoters(); + this.#updateStatusBadge(); + + // Wire events + const onChange = () => this.dispatchEvent(new CustomEvent("content-change")); + + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + onChange(); + }); + + this.#targetEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#targetShapeId = this.#targetEl.value; + this.#updateDiff(); + onChange(); + }); + + this.#replTypeEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#replacementType = this.#replTypeEl.value; + afterBox.textContent = `type: ${this.#replacementType}`; + onChange(); + }); + + this.#modeEl.addEventListener("change", (e) => { + e.stopPropagation(); + this.#approvalMode = this.#modeEl.value as ApprovalMode; + this.#checkApproval(); + onChange(); + }); + + this.#descEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#description = this.#descEl.value; + onChange(); + }); + + // Vote buttons + wrapper.querySelector(".vote-btn.approve")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.#castVote(true); + onChange(); + }); + + wrapper.querySelector(".vote-btn.reject")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.#castVote(false); + onChange(); + }); + + // Apply button + this.#applyBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.#applyAmendment(); + onChange(); + }); + + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Prevent drag on inputs + for (const el of wrapper.querySelectorAll("input, textarea, select, button")) { + el.addEventListener("pointerdown", (e) => e.stopPropagation()); + } + + // Handle approval-in port + this.addEventListener("port-value-changed", ((e: CustomEvent) => { + const { name, value } = e.detail; + if (name === "approval-in" && value && typeof value === "object") { + const v = value as any; + if (v.voter && v.approve !== undefined) { + this.#votes.push({ + voter: v.voter, + approve: !!v.approve, + timestamp: v.timestamp || Date.now(), + }); + this.#renderVoters(); + this.#checkApproval(); + onChange(); + } + } + }) as EventListener); + + return root; + } + + #updateDiff() { + if (!this.#beforeBox) return; + if (!this.#targetShapeId) { + this.#beforeBox.textContent = "(no target selected)"; + return; + } + const target = document.getElementById(this.#targetShapeId) as any; + if (!target) { + this.#beforeBox.textContent = `(shape "${this.#targetShapeId}" not found)`; + return; + } + const data = target.toJSON?.() || {}; + this.#beforeBox.textContent = `type: ${data.type || target.tagName}\ntitle: ${data.title || "—"}`; + } + + #castVote(approve: boolean) { + const voter = this.#voteNameEl?.value.trim() || "anonymous"; + // Prevent duplicate votes from same voter + const existing = this.#votes.findIndex(v => v.voter === voter); + if (existing >= 0) { + this.#votes[existing] = { voter, approve, timestamp: Date.now() }; + } else { + this.#votes.push({ voter, approve, timestamp: Date.now() }); + } + if (this.#voteNameEl) this.#voteNameEl.value = ""; + this.#renderVoters(); + this.#checkApproval(); + } + + #renderVoters() { + if (!this.#votersEl) return; + this.#votersEl.innerHTML = this.#votes.map(v => { + const badge = v.approve + ? `YES` + : `NO`; + return `
${v.voter}${badge}
`; + }).join(""); + } + + #checkApproval() { + if (this.#amendmentStatus === "applied") return; + + const approvals = this.#votes.filter(v => v.approve).length; + const rejections = this.#votes.filter(v => !v.approve).length; + const total = this.#votes.length; + + let approved = false; + if (this.#approvalMode === "single") { + approved = approvals >= 1; + } else if (this.#approvalMode === "majority") { + approved = total > 0 && approvals > total / 2; + } else if (this.#approvalMode === "unanimous") { + approved = total > 0 && rejections === 0; + } + + if (approved) { + this.#amendmentStatus = "approved"; + } else if (total > 0 && this.#approvalMode === "unanimous" && rejections > 0) { + this.#amendmentStatus = "rejected"; + } else { + this.#amendmentStatus = "pending"; + } + + this.#updateStatusBadge(); + this.#emitPorts(); + } + + #updateStatusBadge() { + if (!this.#statusEl || !this.#applyBtn) return; + this.#statusEl.textContent = this.#amendmentStatus.toUpperCase(); + this.#statusEl.className = `status-badge ${this.#amendmentStatus}`; + this.#applyBtn.disabled = this.#amendmentStatus !== "approved"; + } + + #applyAmendment() { + if (this.#amendmentStatus !== "approved") return; + if (!this.#targetShapeId) return; + + // Dispatch event for canvas to handle the shape replacement + this.dispatchEvent(new CustomEvent("gov-amendment-apply", { + bubbles: true, + composed: true, + detail: { + targetShapeId: this.#targetShapeId, + replacementType: this.#replacementType, + replacementData: this.#replacementData, + amendmentId: this.id, + }, + })); + + this.#amendmentStatus = "applied"; + this.#updateStatusBadge(); + this.#emitPorts(); + } + + #emitPorts() { + const data = { + status: this.#amendmentStatus, + targetShapeId: this.#targetShapeId, + approved: this.#amendmentStatus === "approved" || this.#amendmentStatus === "applied", + }; + this.setPortValue("amendment-out", data); + this.setPortValue("gate-out", { + satisfied: this.#amendmentStatus === "approved" || this.#amendmentStatus === "applied", + }); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-amendment", + title: this.#title, + targetShapeId: this.#targetShapeId, + replacementType: this.#replacementType, + replacementData: this.#replacementData, + approvalMode: this.#approvalMode, + votes: this.#votes, + amendmentStatus: this.#amendmentStatus, + description: this.#description, + }; + } + + static override fromData(data: Record): FolkGovAmendment { + const shape = FolkShape.fromData.call(this, data) as FolkGovAmendment; + if (data.title !== undefined) shape.title = data.title; + if (data.targetShapeId !== undefined) shape.targetShapeId = data.targetShapeId; + if (data.replacementType !== undefined) shape.replacementType = data.replacementType; + if (data.replacementData !== undefined) shape.replacementData = data.replacementData; + if (data.approvalMode !== undefined) shape.approvalMode = data.approvalMode; + if (data.votes !== undefined) shape.votes = data.votes; + if (data.amendmentStatus !== undefined) shape.amendmentStatus = data.amendmentStatus; + if (data.description !== undefined) shape.description = data.description; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + if (data.targetShapeId !== undefined && data.targetShapeId !== this.#targetShapeId) this.targetShapeId = data.targetShapeId; + if (data.replacementType !== undefined && data.replacementType !== this.#replacementType) this.replacementType = data.replacementType; + if (data.replacementData !== undefined) this.replacementData = data.replacementData; + if (data.approvalMode !== undefined && data.approvalMode !== this.#approvalMode) this.approvalMode = data.approvalMode; + if (data.votes !== undefined) this.votes = data.votes; + if (data.amendmentStatus !== undefined && data.amendmentStatus !== this.#amendmentStatus) this.amendmentStatus = data.amendmentStatus; + if (data.description !== undefined && data.description !== this.#description) this.description = data.description; + } +} diff --git a/lib/folk-gov-binary.ts b/lib/folk-gov-binary.ts new file mode 100644 index 0000000..85f977b --- /dev/null +++ b/lib/folk-gov-binary.ts @@ -0,0 +1,345 @@ +/** + * folk-gov-binary — Yes/No Signoff Gate + * + * A simple binary governance gate. An optional assignee can be specified + * (who must sign off). When checked, emits { satisfied: true } on gate-out. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; +import type { PortDescriptor } from "./data-types"; + +const HEADER_COLOR = "#7c3aed"; + +const styles = css` + :host { + background: var(--rs-bg-surface, #1e293b); + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); + min-width: 200px; + min-height: 80px; + overflow: hidden; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: ${HEADER_COLOR}; + color: white; + font-size: 12px; + font-weight: 600; + cursor: move; + border-radius: 10px 10px 0 0; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .body { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 16px; + gap: 8px; + } + + .title-input { + background: transparent; + border: none; + color: var(--rs-text-primary, #e2e8f0); + font-size: 13px; + font-weight: 600; + text-align: center; + width: 100%; + outline: none; + } + + .title-input::placeholder { + color: var(--rs-text-muted, #64748b); + } + + .assignee-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + } + + .assignee-input { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 11px; + padding: 2px 6px; + width: 100px; + outline: none; + } + + .check-area { + display: flex; + align-items: center; + justify-content: center; + margin-top: 4px; + } + + .gate-checkbox { + width: 36px; + height: 36px; + appearance: none; + -webkit-appearance: none; + border: 3px solid rgba(255, 255, 255, 0.3); + border-radius: 8px; + background: transparent; + cursor: pointer; + position: relative; + transition: all 0.2s; + } + + .gate-checkbox:checked { + background: #22c55e; + border-color: #22c55e; + box-shadow: 0 0 12px rgba(34, 197, 94, 0.4); + } + + .gate-checkbox:checked::after { + content: "✓"; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 20px; + font-weight: 700; + } + + .gate-checkbox:not(:checked) { + box-shadow: 0 0 8px rgba(245, 158, 11, 0.3); + border-color: #f59e0b; + } + + .status-label { + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .status-label.satisfied { + color: #22c55e; + } + + .status-label.waiting { + color: #f59e0b; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-gov-binary": FolkGovBinary; + } +} + +export class FolkGovBinary extends FolkShape { + static override tagName = "folk-gov-binary"; + + static override portDescriptors: PortDescriptor[] = [ + { name: "gate-out", type: "json", direction: "output" }, + ]; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n"); + const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #title = "Signoff Required"; + #assignee = ""; + #satisfied = false; + #signedBy = ""; + #timestamp = 0; + + // DOM refs + #titleEl!: HTMLInputElement; + #assigneeEl!: HTMLInputElement; + #checkboxEl!: HTMLInputElement; + #statusEl!: HTMLElement; + + get title() { return this.#title; } + set title(v: string) { + this.#title = v; + if (this.#titleEl) this.#titleEl.value = v; + } + + get assignee() { return this.#assignee; } + set assignee(v: string) { + this.#assignee = v; + if (this.#assigneeEl) this.#assigneeEl.value = v; + } + + get satisfied() { return this.#satisfied; } + set satisfied(v: boolean) { + this.#satisfied = v; + if (this.#checkboxEl) this.#checkboxEl.checked = v; + this.#updateVisuals(); + this.#emitPort(); + } + + get signedBy() { return this.#signedBy; } + set signedBy(v: string) { this.#signedBy = v; } + + get timestamp() { return this.#timestamp; } + set timestamp(v: number) { this.#timestamp = v; } + + override createRenderRoot() { + const root = super.createRenderRoot(); + this.initPorts(); + + const wrapper = document.createElement("div"); + wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; + wrapper.innerHTML = html` +
+ ⚖️ Binary Gate + + + +
+
+ +
+ Assignee: + +
+
+ +
+ WAITING +
+ `; + + const slot = root.querySelector("slot"); + const container = slot?.parentElement as HTMLElement; + if (container) container.replaceWith(wrapper); + + // Cache refs + this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; + this.#assigneeEl = wrapper.querySelector(".assignee-input") as HTMLInputElement; + this.#checkboxEl = wrapper.querySelector(".gate-checkbox") as HTMLInputElement; + this.#statusEl = wrapper.querySelector(".status-label") as HTMLElement; + + // Set initial values + this.#titleEl.value = this.#title; + this.#assigneeEl.value = this.#assignee; + this.#checkboxEl.checked = this.#satisfied; + this.#updateVisuals(); + + // Wire events + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#assigneeEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#assignee = this.#assigneeEl.value; + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#checkboxEl.addEventListener("change", (e) => { + e.stopPropagation(); + this.#satisfied = this.#checkboxEl.checked; + this.#signedBy = this.#satisfied ? (this.#assignee || "anonymous") : ""; + this.#timestamp = this.#satisfied ? Date.now() : 0; + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Prevent drag on inputs + this.#titleEl.addEventListener("pointerdown", (e) => e.stopPropagation()); + this.#assigneeEl.addEventListener("pointerdown", (e) => e.stopPropagation()); + this.#checkboxEl.addEventListener("pointerdown", (e) => e.stopPropagation()); + + return root; + } + + #updateVisuals() { + if (!this.#statusEl) return; + if (this.#satisfied) { + this.#statusEl.textContent = "SATISFIED"; + this.#statusEl.className = "status-label satisfied"; + } else { + this.#statusEl.textContent = "WAITING"; + this.#statusEl.className = "status-label waiting"; + } + } + + #emitPort() { + this.setPortValue("gate-out", { + satisfied: this.#satisfied, + signedBy: this.#signedBy, + timestamp: this.#timestamp, + }); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-binary", + title: this.#title, + assignee: this.#assignee, + satisfied: this.#satisfied, + signedBy: this.#signedBy, + timestamp: this.#timestamp, + }; + } + + static override fromData(data: Record): FolkGovBinary { + const shape = FolkShape.fromData.call(this, data) as FolkGovBinary; + if (data.title !== undefined) shape.title = data.title; + if (data.assignee !== undefined) shape.assignee = data.assignee; + if (data.satisfied !== undefined) shape.satisfied = data.satisfied; + if (data.signedBy !== undefined) shape.signedBy = data.signedBy; + if (data.timestamp !== undefined) shape.timestamp = data.timestamp; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + if (data.assignee !== undefined && data.assignee !== this.#assignee) this.assignee = data.assignee; + if (data.satisfied !== undefined && data.satisfied !== this.#satisfied) this.satisfied = data.satisfied; + if (data.signedBy !== undefined && data.signedBy !== this.#signedBy) this.signedBy = data.signedBy; + if (data.timestamp !== undefined && data.timestamp !== this.#timestamp) this.timestamp = data.timestamp; + } +} diff --git a/lib/folk-gov-knob.ts b/lib/folk-gov-knob.ts new file mode 100644 index 0000000..2202743 --- /dev/null +++ b/lib/folk-gov-knob.ts @@ -0,0 +1,518 @@ +/** + * folk-gov-knob — Adjustable Parameter + * + * An SVG rotary knob (225° sweep) with numeric input fallback. + * Optional "temporal viscosity" cooldown delays value propagation. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; +import type { PortDescriptor } from "./data-types"; + +const HEADER_COLOR = "#b45309"; +const SWEEP = 225; // degrees +const START_ANGLE = (360 - SWEEP) / 2 + 90; // start from bottom-left + +const styles = css` + :host { + background: var(--rs-bg-surface, #1e293b); + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); + min-width: 160px; + min-height: 120px; + overflow: hidden; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: ${HEADER_COLOR}; + color: white; + font-size: 12px; + font-weight: 600; + cursor: move; + border-radius: 10px 10px 0 0; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .body { + display: flex; + flex-direction: column; + align-items: center; + padding: 12px; + gap: 8px; + } + + .title-input { + background: transparent; + border: none; + color: var(--rs-text-primary, #e2e8f0); + font-size: 12px; + font-weight: 600; + text-align: center; + width: 100%; + outline: none; + } + + .title-input::placeholder { + color: var(--rs-text-muted, #64748b); + } + + .knob-svg { + cursor: grab; + user-select: none; + } + + .knob-svg:active { + cursor: grabbing; + } + + .value-row { + display: flex; + align-items: center; + gap: 4px; + } + + .value-input { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 12px; + font-weight: 600; + padding: 2px 6px; + width: 60px; + text-align: center; + outline: none; + } + + .unit-label { + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + } + + .range-row { + display: flex; + gap: 4px; + font-size: 10px; + color: var(--rs-text-muted, #64748b); + } + + .range-input { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 3px; + color: var(--rs-text-muted, #94a3b8); + font-size: 10px; + padding: 1px 4px; + width: 40px; + text-align: center; + outline: none; + } + + .cooldown-row { + display: flex; + align-items: center; + gap: 4px; + font-size: 10px; + color: var(--rs-text-muted, #64748b); + } + + .cooldown-input { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 3px; + color: var(--rs-text-muted, #94a3b8); + font-size: 10px; + padding: 1px 4px; + width: 36px; + text-align: center; + outline: none; + } + + .cooldown-active { + color: #f59e0b; + font-weight: 600; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-gov-knob": FolkGovKnob; + } +} + +export class FolkGovKnob extends FolkShape { + static override tagName = "folk-gov-knob"; + + static override portDescriptors: PortDescriptor[] = [ + { name: "value-out", type: "number", direction: "output" }, + ]; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n"); + const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #title = "Parameter"; + #min = 0; + #max = 100; + #step = 1; + #value = 50; + #unit = ""; + #cooldown = 0; // seconds (0 = disabled) + #cooldownRemaining = 0; + #cooldownTimer: ReturnType | null = null; + #pendingValue: number | null = null; + + // DOM refs + #titleEl!: HTMLInputElement; + #valueEl!: HTMLInputElement; + #knobSvg!: SVGSVGElement; + #knobArc!: SVGPathElement; + #knobDot!: SVGCircleElement; + #cooldownRing!: SVGCircleElement; + #cooldownLabel!: HTMLElement; + + get title() { return this.#title; } + set title(v: string) { + this.#title = v; + if (this.#titleEl) this.#titleEl.value = v; + } + + get min() { return this.#min; } + set min(v: number) { this.#min = v; this.#updateKnob(); } + + get max() { return this.#max; } + set max(v: number) { this.#max = v; this.#updateKnob(); } + + get step() { return this.#step; } + set step(v: number) { this.#step = v; } + + get value() { return this.#value; } + set value(v: number) { + this.#value = Math.max(this.#min, Math.min(this.#max, v)); + if (this.#valueEl) this.#valueEl.value = String(this.#value); + this.#updateKnob(); + } + + get unit() { return this.#unit; } + set unit(v: string) { this.#unit = v; } + + get cooldown() { return this.#cooldown; } + set cooldown(v: number) { this.#cooldown = Math.max(0, v); } + + override createRenderRoot() { + const root = super.createRenderRoot(); + this.initPorts(); + + const wrapper = document.createElement("div"); + wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; + wrapper.innerHTML = html` +
+ 🎛️ Knob + + + +
+
+ + + + + + + +
+ + +
+
+ min + + max + + step + +
+
+ cooldown + + s + +
+
+ `; + + const slot = root.querySelector("slot"); + const container = slot?.parentElement as HTMLElement; + if (container) container.replaceWith(wrapper); + + // Cache refs + this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; + this.#valueEl = wrapper.querySelector(".value-input") as HTMLInputElement; + this.#knobSvg = wrapper.querySelector(".knob-svg") as unknown as SVGSVGElement; + this.#knobArc = wrapper.querySelector(".knob-arc") as unknown as SVGPathElement; + this.#knobDot = wrapper.querySelector(".knob-dot") as unknown as SVGCircleElement; + this.#cooldownRing = wrapper.querySelector(".cooldown-ring") as unknown as SVGCircleElement; + this.#cooldownLabel = wrapper.querySelector(".cooldown-active") as HTMLElement; + + const minEl = wrapper.querySelector(".min-input") as HTMLInputElement; + const maxEl = wrapper.querySelector(".max-input") as HTMLInputElement; + const stepEl = wrapper.querySelector(".step-input") as HTMLInputElement; + const cooldownEl = wrapper.querySelector(".cooldown-input") as HTMLInputElement; + const unitLabel = wrapper.querySelector(".unit-label") as HTMLElement; + + // Set initial values + this.#titleEl.value = this.#title; + this.#valueEl.value = String(this.#value); + minEl.value = String(this.#min); + maxEl.value = String(this.#max); + stepEl.value = String(this.#step); + cooldownEl.value = String(this.#cooldown); + unitLabel.textContent = this.#unit; + this.#updateKnob(); + + // Wire events + const onChange = () => this.dispatchEvent(new CustomEvent("content-change")); + + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + onChange(); + }); + + this.#valueEl.addEventListener("input", (e) => { + e.stopPropagation(); + const v = parseFloat(this.#valueEl.value); + if (!isNaN(v)) this.#setValueWithCooldown(v); + onChange(); + }); + + minEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#min = parseFloat(minEl.value) || 0; + this.#updateKnob(); + onChange(); + }); + + maxEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#max = parseFloat(maxEl.value) || 100; + this.#updateKnob(); + onChange(); + }); + + stepEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#step = parseFloat(stepEl.value) || 1; + onChange(); + }); + + cooldownEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#cooldown = parseFloat(cooldownEl.value) || 0; + onChange(); + }); + + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Knob drag interaction + let dragging = false; + this.#knobSvg.addEventListener("pointerdown", (e) => { + e.stopPropagation(); + dragging = true; + (e.target as Element).setPointerCapture(e.pointerId); + }); + + this.#knobSvg.addEventListener("pointermove", (e) => { + if (!dragging) return; + e.stopPropagation(); + const rect = this.#knobSvg.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + let angle = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI); + angle = (angle + 360) % 360; + // Map angle to value + const startA = START_ANGLE; + let relAngle = (angle - startA + 360) % 360; + if (relAngle > SWEEP) relAngle = relAngle > SWEEP + (360 - SWEEP) / 2 ? 0 : SWEEP; + const ratio = relAngle / SWEEP; + let v = this.#min + ratio * (this.#max - this.#min); + // Snap to step + v = Math.round(v / this.#step) * this.#step; + v = Math.max(this.#min, Math.min(this.#max, v)); + this.#setValueWithCooldown(v); + onChange(); + }); + + this.#knobSvg.addEventListener("pointerup", (e) => { + dragging = false; + e.stopPropagation(); + }); + + // Prevent drag on all inputs + for (const el of wrapper.querySelectorAll("input, button")) { + el.addEventListener("pointerdown", (e) => e.stopPropagation()); + } + + // Emit initial value + this.#emitPort(); + + return root; + } + + #setValueWithCooldown(v: number) { + v = Math.max(this.#min, Math.min(this.#max, v)); + this.#value = v; + if (this.#valueEl) this.#valueEl.value = String(v); + this.#updateKnob(); + + if (this.#cooldown > 0) { + this.#pendingValue = v; + if (!this.#cooldownTimer) { + this.#cooldownRemaining = this.#cooldown; + this.#cooldownLabel.style.display = ""; + this.#cooldownLabel.textContent = `${this.#cooldownRemaining}s`; + this.#updateCooldownRing(); + this.#cooldownTimer = setInterval(() => { + this.#cooldownRemaining--; + if (this.#cooldownRemaining <= 0) { + clearInterval(this.#cooldownTimer!); + this.#cooldownTimer = null; + this.#cooldownLabel.style.display = "none"; + this.#cooldownRing.setAttribute("stroke-dashoffset", "220"); + if (this.#pendingValue !== null) { + this.#emitPort(); + this.#pendingValue = null; + } + } else { + this.#cooldownLabel.textContent = `${this.#cooldownRemaining}s`; + this.#updateCooldownRing(); + } + }, 1000); + } else { + // Reset countdown + this.#cooldownRemaining = this.#cooldown; + this.#cooldownLabel.textContent = `${this.#cooldownRemaining}s`; + this.#updateCooldownRing(); + } + } else { + this.#emitPort(); + } + } + + #updateCooldownRing() { + if (!this.#cooldownRing || this.#cooldown <= 0) return; + const circumference = 220; // 2 * π * 35 + const progress = this.#cooldownRemaining / this.#cooldown; + this.#cooldownRing.setAttribute("stroke-dashoffset", String(circumference * (1 - progress))); + } + + #updateKnob() { + if (!this.#knobArc || !this.#knobDot) return; + const range = this.#max - this.#min; + const ratio = range > 0 ? (this.#value - this.#min) / range : 0; + const endAngle = START_ANGLE + ratio * SWEEP; + + // Arc path + const r = 35; + const cx = 40; + const cy = 40; + const startRad = (START_ANGLE * Math.PI) / 180; + const endRad = (endAngle * Math.PI) / 180; + const x1 = cx + r * Math.cos(startRad); + const y1 = cy + r * Math.sin(startRad); + const x2 = cx + r * Math.cos(endRad); + const y2 = cy + r * Math.sin(endRad); + const largeArc = ratio * SWEEP > 180 ? 1 : 0; + + if (ratio > 0.001) { + this.#knobArc.setAttribute("d", `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`); + } else { + this.#knobArc.setAttribute("d", ""); + } + + // Dot position + this.#knobDot.setAttribute("cx", String(x2)); + this.#knobDot.setAttribute("cy", String(y2)); + } + + #emitPort() { + this.setPortValue("value-out", this.#value); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this.#cooldownTimer) { + clearInterval(this.#cooldownTimer); + this.#cooldownTimer = null; + } + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-knob", + title: this.#title, + min: this.#min, + max: this.#max, + step: this.#step, + value: this.#value, + unit: this.#unit, + cooldown: this.#cooldown, + }; + } + + static override fromData(data: Record): FolkGovKnob { + const shape = FolkShape.fromData.call(this, data) as FolkGovKnob; + if (data.title !== undefined) shape.title = data.title; + if (data.min !== undefined) shape.min = data.min; + if (data.max !== undefined) shape.max = data.max; + if (data.step !== undefined) shape.step = data.step; + if (data.value !== undefined) shape.value = data.value; + if (data.unit !== undefined) shape.unit = data.unit; + if (data.cooldown !== undefined) shape.cooldown = data.cooldown; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + if (data.min !== undefined && data.min !== this.#min) this.min = data.min; + if (data.max !== undefined && data.max !== this.#max) this.max = data.max; + if (data.step !== undefined && data.step !== this.#step) this.step = data.step; + if (data.value !== undefined && data.value !== this.#value) this.value = data.value; + if (data.unit !== undefined && data.unit !== this.#unit) this.unit = data.unit; + if (data.cooldown !== undefined && data.cooldown !== this.#cooldown) this.cooldown = data.cooldown; + } +} diff --git a/lib/folk-gov-project.ts b/lib/folk-gov-project.ts new file mode 100644 index 0000000..1e14966 --- /dev/null +++ b/lib/folk-gov-project.ts @@ -0,0 +1,456 @@ +/** + * folk-gov-project — Circuit Aggregator + * + * Traverses the arrow graph backward from itself to discover all upstream + * governance gates. Shows "X of Y gates satisfied" with a progress bar + * and requirement checklist. Auto-detects completion. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; +import type { PortDescriptor } from "./data-types"; + +const HEADER_COLOR = "#1d4ed8"; +const GOV_TAG_NAMES = new Set([ + "FOLK-GOV-BINARY", + "FOLK-GOV-THRESHOLD", + "FOLK-GOV-KNOB", + "FOLK-GOV-AMENDMENT", +]); + +type ProjectStatus = "draft" | "active" | "completed" | "archived"; + +interface GateInfo { + id: string; + tagName: string; + title: string; + satisfied: boolean; +} + +const styles = css` + :host { + background: var(--rs-bg-surface, #1e293b); + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); + min-width: 280px; + min-height: 180px; + overflow: hidden; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: ${HEADER_COLOR}; + color: white; + font-size: 12px; + font-weight: 600; + cursor: move; + border-radius: 10px 10px 0 0; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .body { + display: flex; + flex-direction: column; + padding: 12px; + gap: 8px; + overflow-y: auto; + max-height: calc(100% - 36px); + } + + .title-input { + background: transparent; + border: none; + color: var(--rs-text-primary, #e2e8f0); + font-size: 14px; + font-weight: 700; + width: 100%; + outline: none; + } + + .title-input::placeholder { + color: var(--rs-text-muted, #64748b); + } + + .desc-input { + background: transparent; + border: none; + color: var(--rs-text-secondary, #cbd5e1); + font-size: 11px; + width: 100%; + outline: none; + resize: none; + min-height: 24px; + } + + .desc-input::placeholder { + color: var(--rs-text-muted, #475569); + } + + .status-select { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 11px; + padding: 3px 6px; + outline: none; + width: fit-content; + } + + .progress-section { + margin-top: 4px; + } + + .progress-summary { + display: flex; + justify-content: space-between; + font-size: 12px; + font-weight: 600; + color: var(--rs-text-primary, #e2e8f0); + margin-bottom: 4px; + } + + .progress-wrap { + position: relative; + height: 16px; + background: rgba(255, 255, 255, 0.08); + border-radius: 8px; + overflow: hidden; + } + + .progress-bar { + height: 100%; + border-radius: 8px; + transition: width 0.3s, background 0.3s; + background: ${HEADER_COLOR}; + } + + .progress-bar.complete { + background: #22c55e; + box-shadow: 0 0 8px rgba(34, 197, 94, 0.4); + } + + .checklist { + display: flex; + flex-direction: column; + gap: 3px; + margin-top: 6px; + } + + .check-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--rs-text-secondary, #94a3b8); + padding: 3px 6px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.03); + } + + .check-item.satisfied { + color: #22c55e; + } + + .check-icon { + width: 14px; + text-align: center; + font-size: 10px; + } + + .no-gates { + font-size: 11px; + color: var(--rs-text-muted, #475569); + font-style: italic; + text-align: center; + padding: 12px 0; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-gov-project": FolkGovProject; + } +} + +export class FolkGovProject extends FolkShape { + static override tagName = "folk-gov-project"; + + static override portDescriptors: PortDescriptor[] = [ + { name: "circuit-out", type: "json", direction: "output" }, + ]; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n"); + const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #title = "Project"; + #description = ""; + #status: ProjectStatus = "draft"; + #pollInterval: ReturnType | null = null; + + // DOM refs + #titleEl!: HTMLInputElement; + #descEl!: HTMLTextAreaElement; + #statusEl!: HTMLSelectElement; + #summaryEl!: HTMLElement; + #progressBar!: HTMLElement; + #checklistEl!: HTMLElement; + + get title() { return this.#title; } + set title(v: string) { + this.#title = v; + if (this.#titleEl) this.#titleEl.value = v; + } + + get description() { return this.#description; } + set description(v: string) { + this.#description = v; + if (this.#descEl) this.#descEl.value = v; + } + + get status(): ProjectStatus { return this.#status; } + set status(v: ProjectStatus) { + this.#status = v; + if (this.#statusEl) this.#statusEl.value = v; + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + this.initPorts(); + + const wrapper = document.createElement("div"); + wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; + wrapper.innerHTML = html` +
+ 🏗️ Project + + + +
+
+ + + +
+
+ 0 of 0 gates +
+
+
+
+
+
+
+ `; + + const slot = root.querySelector("slot"); + const container = slot?.parentElement as HTMLElement; + if (container) container.replaceWith(wrapper); + + // Cache refs + this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; + this.#descEl = wrapper.querySelector(".desc-input") as HTMLTextAreaElement; + this.#statusEl = wrapper.querySelector(".status-select") as HTMLSelectElement; + this.#summaryEl = wrapper.querySelector(".summary-text") as HTMLElement; + this.#progressBar = wrapper.querySelector(".progress-bar") as HTMLElement; + this.#checklistEl = wrapper.querySelector(".checklist") as HTMLElement; + + // Set initial values + this.#titleEl.value = this.#title; + this.#descEl.value = this.#description; + this.#statusEl.value = this.#status; + + // Wire events + const onChange = () => this.dispatchEvent(new CustomEvent("content-change")); + + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + onChange(); + }); + + this.#descEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#description = this.#descEl.value; + onChange(); + }); + + this.#statusEl.addEventListener("change", (e) => { + e.stopPropagation(); + this.#status = this.#statusEl.value as ProjectStatus; + onChange(); + }); + + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Prevent drag on inputs + for (const el of wrapper.querySelectorAll("input, textarea, select, button")) { + el.addEventListener("pointerdown", (e) => e.stopPropagation()); + } + + // Poll upstream gates every 2 seconds (pull-based) + this.#pollInterval = setInterval(() => this.#scanUpstreamGates(), 2000); + // Also scan immediately + requestAnimationFrame(() => this.#scanUpstreamGates()); + + return root; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this.#pollInterval) { + clearInterval(this.#pollInterval); + this.#pollInterval = null; + } + } + + /** + * Walk the arrow graph backward from this shape to find all upstream + * governance gates. Returns GateInfo[] for each discovered gate. + */ + #scanUpstreamGates(): void { + const gates: GateInfo[] = []; + const visited = new Set(); + const queue: string[] = [this.id]; + + // Find all arrows in the document + const arrows = document.querySelectorAll("folk-arrow"); + + while (queue.length > 0) { + const currentId = queue.shift()!; + if (visited.has(currentId)) continue; + visited.add(currentId); + + // Find arrows targeting this shape + for (const arrow of arrows) { + const a = arrow as any; + if (a.targetId === currentId) { + const sourceId = a.sourceId; + if (!sourceId || visited.has(sourceId)) continue; + + const sourceEl = document.getElementById(sourceId) as any; + if (!sourceEl) continue; + + const tagName = sourceEl.tagName?.toUpperCase(); + if (GOV_TAG_NAMES.has(tagName)) { + const portVal = sourceEl.getPortValue?.("gate-out"); + gates.push({ + id: sourceId, + tagName, + title: sourceEl.title || sourceEl.getAttribute?.("title") || tagName, + satisfied: portVal?.satisfied === true, + }); + } + + queue.push(sourceId); + } + } + } + + this.#renderGates(gates); + } + + #renderGates(gates: GateInfo[]) { + const total = gates.length; + const completed = gates.filter(g => g.satisfied).length; + const pct = total > 0 ? (completed / total) * 100 : 0; + const allDone = total > 0 && completed === total; + + // Auto-detect completion + if (allDone && this.#status === "active") { + this.#status = "completed"; + if (this.#statusEl) this.#statusEl.value = "completed"; + this.dispatchEvent(new CustomEvent("content-change")); + } + + if (this.#summaryEl) { + this.#summaryEl.textContent = `${completed} of ${total} gates`; + } + + if (this.#progressBar) { + this.#progressBar.style.width = `${pct}%`; + this.#progressBar.classList.toggle("complete", allDone); + } + + if (this.#checklistEl) { + if (total === 0) { + this.#checklistEl.innerHTML = `
Connect gov gates upstream to track progress
`; + } else { + this.#checklistEl.innerHTML = gates.map(g => { + const icon = g.satisfied ? "✓" : "○"; + const cls = g.satisfied ? "check-item satisfied" : "check-item"; + const typeLabel = g.tagName.replace("FOLK-GOV-", "").toLowerCase(); + return `
${icon}${g.title} (${typeLabel})
`; + }).join(""); + } + } + + // Emit port + this.setPortValue("circuit-out", { + status: this.#status, + completedGates: completed, + totalGates: total, + percentage: pct, + }); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-project", + title: this.#title, + description: this.#description, + status: this.#status, + }; + } + + static override fromData(data: Record): FolkGovProject { + const shape = FolkShape.fromData.call(this, data) as FolkGovProject; + if (data.title !== undefined) shape.title = data.title; + if (data.description !== undefined) shape.description = data.description; + if (data.status !== undefined) shape.status = data.status; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + if (data.description !== undefined && data.description !== this.#description) this.description = data.description; + if (data.status !== undefined && data.status !== this.#status) this.status = data.status; + } +} diff --git a/lib/folk-gov-threshold.ts b/lib/folk-gov-threshold.ts new file mode 100644 index 0000000..94671b0 --- /dev/null +++ b/lib/folk-gov-threshold.ts @@ -0,0 +1,478 @@ +/** + * folk-gov-threshold — Numeric Progress Gate + * + * Tracks contributions toward a target value. Shows a progress bar, + * turns green when target is met. Accepts contributions via input port + * or direct UI. Target can be dynamically set via knob input port. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; +import type { PortDescriptor } from "./data-types"; + +const HEADER_COLOR = "#0891b2"; + +interface Contribution { + who: string; + amount: number; + timestamp: number; +} + +const styles = css` + :host { + background: var(--rs-bg-surface, #1e293b); + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); + min-width: 220px; + min-height: 100px; + overflow: hidden; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: ${HEADER_COLOR}; + color: white; + font-size: 12px; + font-weight: 600; + cursor: move; + border-radius: 10px 10px 0 0; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .body { + display: flex; + flex-direction: column; + padding: 12px; + gap: 8px; + } + + .title-input { + background: transparent; + border: none; + color: var(--rs-text-primary, #e2e8f0); + font-size: 13px; + font-weight: 600; + width: 100%; + outline: none; + } + + .title-input::placeholder { + color: var(--rs-text-muted, #64748b); + } + + .target-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + } + + .target-input, .unit-input { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 11px; + padding: 2px 6px; + outline: none; + } + + .target-input { + width: 60px; + text-align: right; + } + + .unit-input { + width: 50px; + } + + .progress-wrap { + position: relative; + height: 20px; + background: rgba(255, 255, 255, 0.08); + border-radius: 10px; + overflow: hidden; + } + + .progress-bar { + height: 100%; + border-radius: 10px; + transition: width 0.3s, background 0.3s; + background: ${HEADER_COLOR}; + } + + .progress-bar.complete { + background: #22c55e; + box-shadow: 0 0 8px rgba(34, 197, 94, 0.4); + } + + .progress-label { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 600; + color: white; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + } + + .contribute-row { + display: flex; + gap: 4px; + } + + .contrib-name, .contrib-amount { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 11px; + padding: 3px 6px; + outline: none; + } + + .contrib-name { + flex: 1; + } + + .contrib-amount { + width: 50px; + text-align: right; + } + + .contrib-btn { + background: ${HEADER_COLOR}; + border: none; + color: white; + border-radius: 4px; + padding: 3px 8px; + font-size: 11px; + cursor: pointer; + font-weight: 600; + } + + .contrib-btn:hover { + opacity: 0.85; + } + + .contributions-list { + max-height: 80px; + overflow-y: auto; + font-size: 10px; + color: var(--rs-text-muted, #94a3b8); + } + + .contrib-item { + display: flex; + justify-content: space-between; + padding: 2px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + } + + .status-label { + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + text-align: center; + } + + .status-label.satisfied { + color: #22c55e; + } + + .status-label.waiting { + color: #f59e0b; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-gov-threshold": FolkGovThreshold; + } +} + +export class FolkGovThreshold extends FolkShape { + static override tagName = "folk-gov-threshold"; + + static override portDescriptors: PortDescriptor[] = [ + { name: "contribution-in", type: "json", direction: "input" }, + { name: "target-in", type: "number", direction: "input" }, + { name: "gate-out", type: "json", direction: "output" }, + ]; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n"); + const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #title = "Threshold"; + #target = 100; + #unit = "$"; + #contributions: Contribution[] = []; + + // DOM refs + #titleEl!: HTMLInputElement; + #targetEl!: HTMLInputElement; + #unitEl!: HTMLInputElement; + #progressBar!: HTMLElement; + #progressLabel!: HTMLElement; + #contribList!: HTMLElement; + #statusEl!: HTMLElement; + #contribNameEl!: HTMLInputElement; + #contribAmountEl!: HTMLInputElement; + + get title() { return this.#title; } + set title(v: string) { + this.#title = v; + if (this.#titleEl) this.#titleEl.value = v; + } + + get target() { return this.#target; } + set target(v: number) { + this.#target = v; + if (this.#targetEl) this.#targetEl.value = String(v); + this.#updateVisuals(); + this.#emitPort(); + } + + get unit() { return this.#unit; } + set unit(v: string) { + this.#unit = v; + if (this.#unitEl) this.#unitEl.value = v; + this.#updateVisuals(); + } + + get contributions(): Contribution[] { return [...this.#contributions]; } + set contributions(v: Contribution[]) { + this.#contributions = v; + this.#updateVisuals(); + this.#emitPort(); + } + + get #current(): number { + return this.#contributions.reduce((sum, c) => sum + c.amount, 0); + } + + get #percentage(): number { + return this.#target > 0 ? Math.min(100, (this.#current / this.#target) * 100) : 0; + } + + get #isSatisfied(): boolean { + return this.#current >= this.#target; + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + this.initPorts(); + + const wrapper = document.createElement("div"); + wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; + wrapper.innerHTML = html` +
+ 📊 Threshold + + + +
+
+ +
+ Target: + + +
+
+
+
0 / 100
+
+
+ + + +
+
+ WAITING +
+ `; + + const slot = root.querySelector("slot"); + const container = slot?.parentElement as HTMLElement; + if (container) container.replaceWith(wrapper); + + // Cache refs + this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; + this.#targetEl = wrapper.querySelector(".target-input") as HTMLInputElement; + this.#unitEl = wrapper.querySelector(".unit-input") as HTMLInputElement; + this.#progressBar = wrapper.querySelector(".progress-bar") as HTMLElement; + this.#progressLabel = wrapper.querySelector(".progress-label") as HTMLElement; + this.#contribList = wrapper.querySelector(".contributions-list") as HTMLElement; + this.#statusEl = wrapper.querySelector(".status-label") as HTMLElement; + this.#contribNameEl = wrapper.querySelector(".contrib-name") as HTMLInputElement; + this.#contribAmountEl = wrapper.querySelector(".contrib-amount") as HTMLInputElement; + + // Set initial values + this.#titleEl.value = this.#title; + this.#targetEl.value = String(this.#target); + this.#unitEl.value = this.#unit; + this.#updateVisuals(); + + // Wire events + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#targetEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#target = parseFloat(this.#targetEl.value) || 0; + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#unitEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#unit = this.#unitEl.value; + this.#updateVisuals(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + wrapper.querySelector(".contrib-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + const who = this.#contribNameEl.value.trim() || "anonymous"; + const amount = parseFloat(this.#contribAmountEl.value) || 0; + if (amount <= 0) return; + this.#contributions.push({ who, amount, timestamp: Date.now() }); + this.#contribNameEl.value = ""; + this.#contribAmountEl.value = ""; + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Prevent drag on inputs + for (const el of wrapper.querySelectorAll("input, button")) { + el.addEventListener("pointerdown", (e) => e.stopPropagation()); + } + + // Handle input ports + this.addEventListener("port-value-changed", ((e: CustomEvent) => { + const { name, value } = e.detail; + if (name === "contribution-in" && value && typeof value === "object") { + const c = value as any; + this.#contributions.push({ + who: c.who || c.memberName || "anonymous", + amount: c.amount || c.hours || 0, + timestamp: c.timestamp || Date.now(), + }); + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + } + if (name === "target-in" && typeof value === "number") { + this.#target = value; + if (this.#targetEl) this.#targetEl.value = String(value); + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + } + }) as EventListener); + + return root; + } + + #updateVisuals() { + if (!this.#progressBar) return; + const pct = this.#percentage; + const current = this.#current; + const satisfied = this.#isSatisfied; + + this.#progressBar.style.width = `${pct}%`; + this.#progressBar.classList.toggle("complete", satisfied); + this.#progressLabel.textContent = `${current} / ${this.#target} ${this.#unit}`; + + if (this.#statusEl) { + this.#statusEl.textContent = satisfied ? "SATISFIED" : "WAITING"; + this.#statusEl.className = `status-label ${satisfied ? "satisfied" : "waiting"}`; + } + + // Render contributions list + if (this.#contribList) { + this.#contribList.innerHTML = this.#contributions.map(c => + `
${c.who}${c.amount} ${this.#unit}
` + ).join(""); + } + } + + #emitPort() { + this.setPortValue("gate-out", { + satisfied: this.#isSatisfied, + current: this.#current, + target: this.#target, + percentage: this.#percentage, + }); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-threshold", + title: this.#title, + target: this.#target, + unit: this.#unit, + contributions: this.#contributions, + }; + } + + static override fromData(data: Record): FolkGovThreshold { + const shape = FolkShape.fromData.call(this, data) as FolkGovThreshold; + if (data.title !== undefined) shape.title = data.title; + if (data.target !== undefined) shape.target = data.target; + if (data.unit !== undefined) shape.unit = data.unit; + if (data.contributions !== undefined) shape.contributions = data.contributions; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + if (data.target !== undefined && data.target !== this.#target) this.target = data.target; + if (data.unit !== undefined && data.unit !== this.#unit) this.unit = data.unit; + if (data.contributions !== undefined) this.contributions = data.contributions; + } +} diff --git a/lib/index.ts b/lib/index.ts index 2851848..3d6f9d7 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -81,6 +81,13 @@ export * from "./folk-social-thread"; export * from "./folk-social-campaign"; export * from "./folk-social-newsletter"; +// rGov Governance Circuit Shapes +export * from "./folk-gov-binary"; +export * from "./folk-gov-threshold"; +export * from "./folk-gov-knob"; +export * from "./folk-gov-project"; +export * from "./folk-gov-amendment"; + // Decision/Choice Shapes export * from "./folk-choice-vote"; export * from "./folk-choice-rank"; diff --git a/modules/rgov/mod.ts b/modules/rgov/mod.ts new file mode 100644 index 0000000..1ea9f6a --- /dev/null +++ b/modules/rgov/mod.ts @@ -0,0 +1,89 @@ +/** + * rGov module — Multiplayer governance decision circuits. + * + * Visual circuit builder where people assemble governance decision-making + * systems from drag-and-drop components: binary gates, thresholds, knobs, + * projects, and amendments. + */ + +import { Hono } from "hono"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; + +const routes = new Hono(); + +// ── Landing page ── + +routes.get("/", (c) => { + const space = c.req.param("space") || "demo"; + + return c.html(renderShell({ + title: `${space} — rGov | rSpace`, + moduleId: "rgov", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ` +
+

⚖️ rGov

+

Multiplayer governance decision circuits

+

+ Build decision-making systems by wiring together governance components on the canvas: +

+
    +
  • Binary Gates — Yes/No signoff checkpoints
  • +
  • Thresholds — Numeric targets (hours, dollars, signatures)
  • +
  • Knobs — Adjustable parameters with optional cooldowns
  • +
  • Projects — Circuit aggregators showing overall progress
  • +
  • Amendments — Propose changes to any gate in the circuit
  • +
+ + Open Canvas → + +
+ `, + })); +}); + +// ── API: list gov shapes in a space ── + +routes.get("/api/shapes", (c) => { + // This is a lightweight endpoint — actual shape data lives in Automerge. + // Client-side code queries the shapes map directly. + return c.json({ + info: "Gov shapes are stored in the space's Automerge document. Query the canvas shapes map for types: folk-gov-binary, folk-gov-threshold, folk-gov-knob, folk-gov-project, folk-gov-amendment.", + types: [ + "folk-gov-binary", + "folk-gov-threshold", + "folk-gov-knob", + "folk-gov-project", + "folk-gov-amendment", + ], + }); +}); + +// ── Module export ── + +export const govModule: RSpaceModule = { + id: "rgov", + name: "rGov", + icon: "⚖️", + description: "Multiplayer governance decision circuits", + routes, + scoping: { defaultScope: "space", userConfigurable: false }, + canvasShapes: [ + "folk-gov-binary", + "folk-gov-threshold", + "folk-gov-knob", + "folk-gov-project", + "folk-gov-amendment", + ], + canvasToolIds: [ + "create_binary_gate", + "create_threshold", + "create_gov_knob", + "create_gov_project", + "create_amendment", + ], +}; diff --git a/server/index.ts b/server/index.ts index f819e2c..9ae3b22 100644 --- a/server/index.ts +++ b/server/index.ts @@ -84,6 +84,7 @@ import { bnbModule } from "../modules/rbnb/mod"; import { vnbModule } from "../modules/rvnb/mod"; import { crowdsurfModule } from "../modules/crowdsurf/mod"; import { timeModule } from "../modules/rtime/mod"; +import { govModule } from "../modules/rgov/mod"; import { sheetModule } from "../modules/rsheet/mod"; import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces"; import type { SpaceRoleString } from "./spaces"; @@ -141,6 +142,7 @@ registerModule(bnbModule); registerModule(vnbModule); registerModule(crowdsurfModule); registerModule(timeModule); +registerModule(govModule); // Governance decision circuits registerModule(designModule); // Scribus DTP + AI design agent // De-emphasized modules (bottom of menu) registerModule(forumModule); diff --git a/website/canvas.html b/website/canvas.html index 065d345..9c126fa 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -2460,6 +2460,11 @@ FolkCommitmentPool, FolkTaskRequest, FolkTransactionBuilder, + FolkGovBinary, + FolkGovThreshold, + FolkGovKnob, + FolkGovProject, + FolkGovAmendment, FolkChoiceVote, FolkChoiceRank, FolkChoiceSpider, @@ -2729,6 +2734,11 @@ FolkCommitmentPool.define(); FolkTaskRequest.define(); FolkTransactionBuilder.define(); + FolkGovBinary.define(); + FolkGovThreshold.define(); + FolkGovKnob.define(); + FolkGovProject.define(); + FolkGovAmendment.define(); FolkChoiceVote.define(); FolkChoiceRank.define(); FolkChoiceSpider.define(); @@ -2784,6 +2794,11 @@ shapeRegistry.register("folk-commitment-pool", FolkCommitmentPool); shapeRegistry.register("folk-task-request", FolkTaskRequest); shapeRegistry.register("folk-transaction-builder", FolkTransactionBuilder); + shapeRegistry.register("folk-gov-binary", FolkGovBinary); + shapeRegistry.register("folk-gov-threshold", FolkGovThreshold); + shapeRegistry.register("folk-gov-knob", FolkGovKnob); + shapeRegistry.register("folk-gov-project", FolkGovProject); + shapeRegistry.register("folk-gov-amendment", FolkGovAmendment); shapeRegistry.register("folk-choice-vote", FolkChoiceVote); shapeRegistry.register("folk-choice-rank", FolkChoiceRank); shapeRegistry.register("folk-choice-spider", FolkChoiceSpider); From 559d14609961c6ce032e4a8a1de80f53c1ae24bd Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 13:07:02 -0700 Subject: [PATCH 3/3] feat(rgov): add GovMods landing page and onboarding Rich landing page with do-ocratic framing, SVG circuit diagram, "GovMods" branding, modular governance vs monolithic comparison, and onboarding action. Module now has landingPage function for bare-domain rendering. Co-Authored-By: Claude Opus 4.6 --- modules/rgov/landing.ts | 385 ++++++++++++++++++++++++++++++++++++++++ modules/rgov/mod.ts | 38 ++-- 2 files changed, 406 insertions(+), 17 deletions(-) create mode 100644 modules/rgov/landing.ts diff --git a/modules/rgov/landing.ts b/modules/rgov/landing.ts new file mode 100644 index 0000000..79de05f --- /dev/null +++ b/modules/rgov/landing.ts @@ -0,0 +1,385 @@ +/** + * rGov landing page — modular governance decision circuits. + * GovMods: do-ocratic circuit components for multiplayer collaboration. + */ +export function renderLanding(): string { + return ` + +
+ + Modular Governance for rSpace + +

+ GovMods +

+

+ Do-ocratic circuit components for multiplayer collaboration + around shared goals. Wire together governance primitives on a shared canvas — thresholds, + signoffs, tunable knobs, and amendable circuits. Decisions happen by doing, + not debating. +

+ +
+ + +
+
+
+ MODULAR GOVERNANCE +

+ What are GovMods? +

+

+ GovMods are do-ocratic governance primitives — + drag-and-drop circuit components where decisions happen through action, + not deliberation. Contribute hours, pledge funds, sign off on requirements. When all gates + in a circuit are satisfied, the decision is made. No meetings required. +

+
+ +
+ +
+
+
+ +
+

Signoff Gate

+
+

+ The simplest GovMod. A binary yes/no checkpoint — assign someone to approve, + or leave it open for anyone who steps up. Green glow when satisfied. + Do-ocracy: whoever shows up, decides. +

+
+ + +
+
+
+ +
+

Threshold Gate

+
+

+ Accumulate contributions toward a target: hours, dollars, signatures, materials. + Progress bar fills as people contribute. Gate opens when the community has collectively + done enough. + Decisions backed by real resources. +

+
+ + +
+
+
+ 🎛️ +
+

Tunable Knob

+
+

+ Adjustable parameters that wire into other GovMods. Set a budget cap, quorum + percentage, or time limit. Optional temporal viscosity: + a cooldown that prevents rapid parameter flipping. + Governance that adapts, but deliberately. +

+
+
+
+
+ + +
+
+
+ + Do-ocracy in Action + +

+ Design → Wire → Do +

+

+ Three steps from blank canvas to living governance. No proposals, no quorum calls — + just wire up the conditions and let people act. +

+
+ +
+ +
+
+
+ 1 +
+
+ Design +

Place GovMods

+
+
+

+ Drag governance components onto the canvas: signoff gates, resource thresholds, + tunable knobs. Or tell MI: "create a governance circuit + for building a climbing wall" and watch the GovMods appear. +

+
+ + +
+
+
+ 2 +
+
+ Wire +

Connect the Circuit

+
+
+

+ Draw arrows from GovMod outputs to a Project aggregator. + Wire a knob's value to a threshold's target for dynamic parameters. + The circuit shows data flow and gate conditions in real time. +

+
+ + +
+
+
+ 3 +
+
+ Do +

Contribute & Complete

+
+
+

+ Community members do the work: contribute resources, sign off, adjust parameters. + The Project tracks "X of Y gates satisfied" + and auto-completes when all conditions are met through collective action. +

+
+
+
+
+ + +
+
+
+

+ Example: Build a Climbing Wall +

+

+ A community wants to build a climbing wall. Here's how GovMods make it happen + through do-ocratic action: +

+
+ + +
+ + + + Proprietor + Signoff + + + + + Labor + 50 hours + + + 30/50 hrs + + + + Capital + $3,000 + + + $2,700/$3,000 + + + + + + + + + Build Climbing Wall + 1 of 3 gates satisfied + + + 33% + + + + Budget Cap + $3,000 + + +
+ +
+
+

The GovMod Circuit

+
    +
  • Signoff: Proprietor approval
  • +
  • Threshold: 50 hours labor (people pledge hours)
  • +
  • Threshold: $3,000 capital (people contribute funds)
  • +
  • Knob: Budget Cap → wires to capital target
  • +
  • Project: aggregates all gates, tracks completion
  • +
+
+
+

The Amendment

+

+ Someone offers to donate climbing grips. They create an amendment GovMod + proposing to replace the $3,000 threshold with a simple signoff + ("Grips donated?"). The community votes on the amendment, and on approval the circuit + rewires automatically — all arrows stay connected. The governance system evolved + because someone did something. +

+
+
+
+
+ + +
+
+
+

+ Why Modular Governance? +

+

+ Traditional governance is monolithic: one system fits all. GovMods let each + community wire exactly the decision process they need. +

+
+ +
+ +
+
+ +

Monolithic Governance

+
+
    +
  • One-size-fits-all voting — everything needs a meeting
  • +
  • Decisions bottleneck on the few people who attend
  • +
  • Resource requirements invisible until someone asks
  • +
  • Governance structure is fixed — can't adapt to the situation
  • +
+
+ + +
+
+ +

GovMod Circuits

+
+
    +
  • Each decision gets exactly the governance it needs
  • +
  • Do-ocratic: contribute resources, don't just vote on them
  • +
  • Progress visible to everyone on a shared canvas
  • +
  • Amendments let governance evolve mid-process
  • +
+
+
+
+
+ + +
+
+
+

+ Built for Do-ocratic Communities +

+
+ +
+
+
+ 🔌 +
+

Modular

+

Mix and match GovMods to model any decision. Compose simple primitives into complex governance.

+
+ +
+
+ 👥 +
+

Multiplayer

+

Real-time CRDT sync. Multiple people contribute, sign off, and adjust knobs simultaneously.

+
+ +
+
+ 🎛️ +
+

Temporal Viscosity

+

Knob cooldowns prevent rapid parameter gaming. Change happens deliberately, not reactively.

+
+ +
+
+ 📝 +
+

Amendable

+

Governance that evolves. Propose circuit changes, vote inline, and the wiring adapts in place.

+
+
+
+
+ + +
+
+
+ + Join the rSpace Ecosystem + +

+ Ready to wire your community's governance? +

+

+ Create a Space and start building GovMod circuits. Drag gates onto the canvas, + wire them together, and let your community decide through action — + visually, collaboratively, and do-ocratically. +

+ +
+
+
+ +`; +} diff --git a/modules/rgov/mod.ts b/modules/rgov/mod.ts index 1ea9f6a..c91aacf 100644 --- a/modules/rgov/mod.ts +++ b/modules/rgov/mod.ts @@ -1,19 +1,21 @@ /** - * rGov module — Multiplayer governance decision circuits. + * rGov module — Modular governance decision circuits (GovMods). * - * Visual circuit builder where people assemble governance decision-making - * systems from drag-and-drop components: binary gates, thresholds, knobs, - * projects, and amendments. + * Do-ocratic circuit components for multiplayer collaboration around + * shared goals. Wire together governance primitives on a shared canvas: + * signoff gates, resource thresholds, tunable knobs, project aggregators, + * and amendable circuits. */ import { Hono } from "hono"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; +import { renderLanding } from "./landing"; const routes = new Hono(); -// ── Landing page ── +// ── Module page (within a space) ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; @@ -26,19 +28,19 @@ routes.get("/", (c) => { theme: "dark", body: `
-

⚖️ rGov

-

Multiplayer governance decision circuits

+

⚖️ rGov — GovMods

+

Do-ocratic circuit components for multiplayer collaboration

- Build decision-making systems by wiring together governance components on the canvas: + Build governance decision circuits by wiring GovMods together on the canvas:

    -
  • Binary Gates — Yes/No signoff checkpoints
  • +
  • Signoff Gates — Yes/No approval checkpoints
  • Thresholds — Numeric targets (hours, dollars, signatures)
  • -
  • Knobs — Adjustable parameters with optional cooldowns
  • -
  • Projects — Circuit aggregators showing overall progress
  • -
  • Amendments — Propose changes to any gate in the circuit
  • +
  • Knobs — Tunable parameters with temporal viscosity
  • +
  • Projects — Circuit aggregators showing "X of Y gates satisfied"
  • +
  • Amendments — Propose in-place circuit modifications
- + Open Canvas →
@@ -49,10 +51,8 @@ routes.get("/", (c) => { // ── API: list gov shapes in a space ── routes.get("/api/shapes", (c) => { - // This is a lightweight endpoint — actual shape data lives in Automerge. - // Client-side code queries the shapes map directly. return c.json({ - info: "Gov shapes are stored in the space's Automerge document. Query the canvas shapes map for types: folk-gov-binary, folk-gov-threshold, folk-gov-knob, folk-gov-project, folk-gov-amendment.", + info: "Gov shapes are stored in the space's Automerge document. Query the canvas shapes map for types listed below.", types: [ "folk-gov-binary", "folk-gov-threshold", @@ -69,9 +69,10 @@ export const govModule: RSpaceModule = { id: "rgov", name: "rGov", icon: "⚖️", - description: "Multiplayer governance decision circuits", + description: "Modular governance decision circuits (GovMods)", routes, scoping: { defaultScope: "space", userConfigurable: false }, + landingPage: renderLanding, canvasShapes: [ "folk-gov-binary", "folk-gov-threshold", @@ -86,4 +87,7 @@ export const govModule: RSpaceModule = { "create_gov_project", "create_amendment", ], + onboardingActions: [ + { label: "Build a Circuit", icon: "⚖️", description: "Create a governance decision circuit on the canvas", type: 'create', href: '/rgov' }, + ], };