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