diff --git a/lib/canvas-tools.ts b/lib/canvas-tools.ts index 4efd152..29f0e87 100644 --- a/lib/canvas-tools.ts +++ b/lib/canvas-tools.ts @@ -650,6 +650,96 @@ registry.push( }), actionLabel: (args) => `Created amendment: ${args.title}`, }, + { + declaration: { + name: "create_quadratic_transform", + description: "Create a quadratic weight transformer on the canvas. Accepts raw weights and applies sqrt/log/linear dampening — useful for reducing whale dominance in voting.", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Transform title (e.g. 'Vote Weight Dampener')" }, + mode: { type: "string", description: "Transform mode", enum: ["sqrt", "log", "linear"] }, + }, + required: ["title"], + }, + }, + tagName: "folk-gov-quadratic", + moduleId: "rgov", + buildProps: (args) => ({ + title: args.title, + ...(args.mode ? { mode: args.mode } : {}), + }), + actionLabel: (args) => `Created quadratic transform: ${args.title}`, + }, + { + declaration: { + name: "create_conviction_gate", + description: "Create a conviction accumulator on the canvas. Accumulates time-weighted conviction from stakes. Gate mode triggers at threshold; tuner mode continuously emits score.", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Gate title (e.g. 'Community Support')" }, + convictionMode: { type: "string", description: "Operating mode", enum: ["gate", "tuner"] }, + threshold: { type: "number", description: "Conviction threshold for gate mode" }, + }, + required: ["title"], + }, + }, + tagName: "folk-gov-conviction", + moduleId: "rgov", + buildProps: (args) => ({ + title: args.title, + ...(args.convictionMode ? { convictionMode: args.convictionMode } : {}), + ...(args.threshold != null ? { threshold: args.threshold } : {}), + }), + actionLabel: (args) => `Created conviction gate: ${args.title}`, + }, + { + declaration: { + name: "create_multisig_gate", + description: "Create an M-of-N multisig gate on the canvas. Requires M named signers before passing. Signers can sign manually or auto-populate from upstream binary gates.", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Multisig title (e.g. 'Council Approval')" }, + requiredM: { type: "number", description: "Number of required signatures (M)" }, + signerNames: { type: "string", description: "Comma-separated signer names" }, + }, + required: ["title"], + }, + }, + tagName: "folk-gov-multisig", + moduleId: "rgov", + buildProps: (args) => ({ + title: args.title, + ...(args.requiredM != null ? { requiredM: args.requiredM } : {}), + ...(args.signerNames ? { + signers: args.signerNames.split(",").map((n: string) => ({ + name: n.trim(), signed: false, timestamp: 0, + })), + } : {}), + }), + actionLabel: (args) => `Created multisig: ${args.title}`, + }, + { + declaration: { + name: "create_sankey_visualizer", + description: "Create a governance flow Sankey visualizer on the canvas. Auto-discovers all nearby gov shapes and renders an animated flow diagram. No ports — purely visual.", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Visualizer title (e.g. 'Governance Flow')" }, + }, + required: ["title"], + }, + }, + tagName: "folk-gov-sankey", + moduleId: "rgov", + buildProps: (args) => ({ + title: args.title, + }), + actionLabel: (args) => `Created Sankey visualizer: ${args.title}`, + }, ); export const CANVAS_TOOLS: CanvasToolDefinition[] = [...registry]; diff --git a/lib/folk-gov-conviction.ts b/lib/folk-gov-conviction.ts new file mode 100644 index 0000000..26756d6 --- /dev/null +++ b/lib/folk-gov-conviction.ts @@ -0,0 +1,606 @@ +/** + * folk-gov-conviction — Conviction Accumulator + * + * Dual-mode GovMod: Gate mode accumulates conviction over time and emits + * satisfied when score >= threshold. Tuner mode continuously emits the + * current conviction score as a dynamic value for downstream wiring. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; +import type { PortDescriptor } from "./data-types"; +import { convictionScore, convictionVelocity } from "./folk-choice-conviction"; +import type { ConvictionStake } from "./folk-choice-conviction"; + +const HEADER_COLOR = "#d97706"; + +type ConvictionMode = "gate" | "tuner"; + +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: 240px; + min-height: 160px; + 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); + } + + .config-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + } + + .mode-select, .threshold-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; + } + + .threshold-input { + width: 60px; + text-align: right; + } + + .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); + } + + .score-display { + font-size: 20px; + font-weight: 700; + color: ${HEADER_COLOR}; + text-align: center; + font-variant-numeric: tabular-nums; + } + + .velocity-label { + font-size: 10px; + color: var(--rs-text-muted, #94a3b8); + text-align: center; + } + + .chart-area svg { + width: 100%; + display: block; + } + + .stakes-list { + max-height: 80px; + overflow-y: auto; + font-size: 10px; + color: var(--rs-text-muted, #94a3b8); + } + + .stake-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; + } + + .status-label.tuner { + color: ${HEADER_COLOR}; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-gov-conviction": FolkGovConviction; + } +} + +export class FolkGovConviction extends FolkShape { + static override tagName = "folk-gov-conviction"; + + static override portDescriptors: PortDescriptor[] = [ + { name: "stake-in", type: "json", direction: "input" }, + { name: "threshold-in", type: "number", direction: "input" }, + { name: "conviction-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 = "Conviction Gate"; + #convictionMode: ConvictionMode = "gate"; + #threshold = 10; + #stakes: ConvictionStake[] = []; + #tickInterval: ReturnType | null = null; + + // DOM refs + #titleEl!: HTMLInputElement; + #modeEl!: HTMLSelectElement; + #thresholdEl!: HTMLInputElement; + #thresholdRow!: HTMLElement; + #progressWrap!: HTMLElement; + #progressBar!: HTMLElement; + #progressLabel!: HTMLElement; + #scoreDisplay!: HTMLElement; + #velocityLabel!: HTMLElement; + #chartEl!: HTMLElement; + #stakesList!: HTMLElement; + #statusEl!: HTMLElement; + + get title() { return this.#title; } + set title(v: string) { + this.#title = v; + if (this.#titleEl) this.#titleEl.value = v; + } + + get convictionMode() { return this.#convictionMode; } + set convictionMode(v: ConvictionMode) { + this.#convictionMode = v; + if (this.#modeEl) this.#modeEl.value = v; + this.#updateLayout(); + this.#updateVisuals(); + this.#emitPorts(); + } + + get threshold() { return this.#threshold; } + set threshold(v: number) { + this.#threshold = v; + if (this.#thresholdEl) this.#thresholdEl.value = String(v); + this.#updateVisuals(); + this.#emitPorts(); + } + + get stakes(): ConvictionStake[] { return [...this.#stakes]; } + set stakes(v: ConvictionStake[]) { + this.#stakes = v; + this.#updateVisuals(); + this.#emitPorts(); + } + + #getTotalScore(): number { + // Aggregate conviction across all stakes (single "option" = this gate) + const now = Date.now(); + let total = 0; + for (const s of this.#stakes) { + total += s.weight * Math.max(0, now - s.since) / 3600000; + } + return total; + } + + #getTotalVelocity(): number { + return this.#stakes.reduce((sum, s) => sum + s.weight, 0); + } + + 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` +
+ ⏳ Conviction + + + +
+
+ +
+ Mode: + + + Threshold: + + +
+
+
+
0 / 10
+
+ +
+
+
+ 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.#modeEl = wrapper.querySelector(".mode-select") as HTMLSelectElement; + this.#thresholdEl = wrapper.querySelector(".threshold-input") as HTMLInputElement; + this.#thresholdRow = wrapper.querySelector(".threshold-row") as HTMLElement; + this.#progressWrap = wrapper.querySelector(".progress-wrap") as HTMLElement; + this.#progressBar = wrapper.querySelector(".progress-bar") as HTMLElement; + this.#progressLabel = wrapper.querySelector(".progress-label") as HTMLElement; + this.#scoreDisplay = wrapper.querySelector(".score-display") as HTMLElement; + this.#velocityLabel = wrapper.querySelector(".velocity-label") as HTMLElement; + this.#chartEl = wrapper.querySelector(".chart-area") as HTMLElement; + this.#stakesList = wrapper.querySelector(".stakes-list") as HTMLElement; + this.#statusEl = wrapper.querySelector(".status-label") as HTMLElement; + + // Set initial values + this.#titleEl.value = this.#title; + this.#modeEl.value = this.#convictionMode; + this.#thresholdEl.value = String(this.#threshold); + this.#updateLayout(); + this.#updateVisuals(); + + // Wire events + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#modeEl.addEventListener("change", (e) => { + e.stopPropagation(); + this.#convictionMode = this.#modeEl.value as ConvictionMode; + this.#updateLayout(); + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#thresholdEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#threshold = parseFloat(this.#thresholdEl.value) || 0; + this.#updateVisuals(); + this.#emitPorts(); + 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, select, button")) { + el.addEventListener("pointerdown", (e) => e.stopPropagation()); + } + + // Handle input ports + this.addEventListener("port-value-changed", ((e: CustomEvent) => { + const { name, value } = e.detail; + if (name === "stake-in" && value && typeof value === "object") { + const v = value as any; + const stake: ConvictionStake = { + userId: v.userId || v.who || crypto.randomUUID().slice(0, 8), + userName: v.userName || v.who || "anonymous", + optionId: "gate", + weight: v.weight || v.amount || 1, + since: v.since || Date.now(), + }; + // Update existing or add + const idx = this.#stakes.findIndex(s => s.userId === stake.userId); + if (idx >= 0) { + this.#stakes[idx] = stake; + } else { + this.#stakes.push(stake); + } + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + } + if (name === "threshold-in" && typeof value === "number") { + this.#threshold = value; + if (this.#thresholdEl) this.#thresholdEl.value = String(value); + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + } + }) as EventListener); + + // Tick timer for live conviction updates + this.#tickInterval = setInterval(() => { + this.#updateVisuals(); + this.#emitPorts(); + }, 10000); + + return root; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this.#tickInterval) { + clearInterval(this.#tickInterval); + this.#tickInterval = null; + } + } + + #updateLayout() { + if (!this.#thresholdRow) return; + const isGate = this.#convictionMode === "gate"; + this.#thresholdRow.style.display = isGate ? "" : "none"; + if (this.#progressWrap) this.#progressWrap.style.display = isGate ? "" : "none"; + if (this.#scoreDisplay) this.#scoreDisplay.style.display = isGate ? "none" : ""; + } + + #updateVisuals() { + const score = this.#getTotalScore(); + const velocity = this.#getTotalVelocity(); + + if (this.#convictionMode === "gate") { + // Gate mode: progress bar + const pct = this.#threshold > 0 ? Math.min(100, (score / this.#threshold) * 100) : 0; + const satisfied = score >= this.#threshold; + + if (this.#progressBar) { + this.#progressBar.style.width = `${pct}%`; + this.#progressBar.classList.toggle("complete", satisfied); + } + if (this.#progressLabel) { + this.#progressLabel.textContent = `${this.#fmtScore(score)} / ${this.#threshold}`; + } + if (this.#statusEl) { + this.#statusEl.textContent = satisfied ? "SATISFIED" : "WAITING"; + this.#statusEl.className = `status-label ${satisfied ? "satisfied" : "waiting"}`; + } + } else { + // Tuner mode: score display + if (this.#scoreDisplay) { + this.#scoreDisplay.textContent = this.#fmtScore(score); + } + if (this.#statusEl) { + this.#statusEl.textContent = "EMITTING"; + this.#statusEl.className = "status-label tuner"; + } + } + + if (this.#velocityLabel) { + this.#velocityLabel.textContent = `velocity: ${velocity.toFixed(1)} wt/hr`; + } + + this.#renderChart(); + this.#renderStakes(); + } + + #renderChart() { + if (!this.#chartEl || this.#stakes.length === 0) { + if (this.#chartEl) this.#chartEl.innerHTML = ""; + return; + } + + const now = Date.now(); + const W = 220; + const H = 60; + const PAD = { top: 6, right: 8, bottom: 12, left: 28 }; + const plotW = W - PAD.left - PAD.right; + const plotH = H - PAD.top - PAD.bottom; + + const earliest = Math.min(...this.#stakes.map(s => s.since)); + const timeRange = Math.max(now - earliest, 60000); + + // Sample conviction curve at 20 points + const SAMPLES = 20; + const points: { t: number; v: number }[] = []; + let maxV = 0; + for (let i = 0; i <= SAMPLES; i++) { + const t = earliest + (timeRange * i) / SAMPLES; + let v = 0; + for (const s of this.#stakes) { + if (s.since <= t) v += s.weight * Math.max(0, t - s.since) / 3600000; + } + points.push({ t, v }); + maxV = Math.max(maxV, v); + } + if (maxV === 0) maxV = 1; + + const x = (t: number) => PAD.left + ((t - earliest) / timeRange) * plotW; + const y = (v: number) => PAD.top + (1 - v / maxV) * plotH; + + let svg = ``; + + // Threshold line in gate mode + if (this.#convictionMode === "gate" && this.#threshold > 0 && this.#threshold <= maxV) { + const ty = y(this.#threshold); + svg += ``; + } + + // Area + const areaD = `M${x(points[0].t)},${y(0)} ` + + points.map(p => `L${x(p.t)},${y(p.v)}`).join(" ") + + ` L${x(points[points.length - 1].t)},${y(0)} Z`; + svg += ``; + + // Line + const lineD = points.map((p, i) => `${i === 0 ? "M" : "L"}${x(p.t)},${y(p.v)}`).join(" "); + svg += ``; + + // End dot + const last = points[points.length - 1]; + svg += ``; + + // Y axis + svg += `${this.#fmtScore(maxV)}`; + svg += `0`; + + svg += ""; + this.#chartEl.innerHTML = svg; + } + + #renderStakes() { + if (!this.#stakesList) return; + const now = Date.now(); + this.#stakesList.innerHTML = this.#stakes.map(s => { + const dur = this.#fmtDuration(now - s.since); + return `
${s.userName} (wt:${s.weight})${dur}
`; + }).join(""); + } + + #emitPorts() { + const score = this.#getTotalScore(); + const velocity = this.#getTotalVelocity(); + const satisfied = this.#convictionMode === "gate" ? score >= this.#threshold : true; + + this.setPortValue("conviction-out", { + score, + velocity, + stakeCount: this.#stakes.length, + mode: this.#convictionMode, + }); + + this.setPortValue("gate-out", { + satisfied, + score, + threshold: this.#threshold, + mode: this.#convictionMode, + }); + } + + #fmtScore(v: number): string { + if (v < 1) return v.toFixed(2); + if (v < 100) return v.toFixed(1); + return Math.round(v).toString(); + } + + #fmtDuration(ms: number): string { + if (ms < 60000) return "<1m"; + if (ms < 3600000) return `${Math.floor(ms / 60000)}m`; + if (ms < 86400000) return `${Math.floor(ms / 3600000)}h`; + return `${Math.floor(ms / 86400000)}d`; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-conviction", + title: this.#title, + convictionMode: this.#convictionMode, + threshold: this.#threshold, + stakes: this.#stakes, + }; + } + + static override fromData(data: Record): FolkGovConviction { + const shape = FolkShape.fromData.call(this, data) as FolkGovConviction; + if (data.title !== undefined) shape.title = data.title; + if (data.convictionMode !== undefined) shape.convictionMode = data.convictionMode; + if (data.threshold !== undefined) shape.threshold = data.threshold; + if (data.stakes !== undefined) shape.stakes = data.stakes; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + if (data.convictionMode !== undefined && data.convictionMode !== this.#convictionMode) this.convictionMode = data.convictionMode; + if (data.threshold !== undefined && data.threshold !== this.#threshold) this.threshold = data.threshold; + if (data.stakes !== undefined && JSON.stringify(data.stakes) !== JSON.stringify(this.#stakes)) this.stakes = data.stakes; + } +} diff --git a/lib/folk-gov-multisig.ts b/lib/folk-gov-multisig.ts new file mode 100644 index 0000000..630289c --- /dev/null +++ b/lib/folk-gov-multisig.ts @@ -0,0 +1,549 @@ +/** + * folk-gov-multisig — M-of-N Multiplexor Gate + * + * Requires M of N named signers before passing. Signers can be added + * manually or auto-populated from upstream binary gates. Shows a + * multiplexor SVG diagram and progress bar. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; +import type { PortDescriptor } from "./data-types"; + +const HEADER_COLOR = "#6366f1"; + +interface Signer { + name: string; + signed: 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: 260px; + 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; + } + + .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); + } + + .mn-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--rs-text-primary, #e2e8f0); + font-weight: 600; + } + + .mn-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; + padding: 2px 6px; + width: 40px; + text-align: center; + outline: none; + } + + .mux-svg { + text-align: center; + } + + .mux-svg svg { + display: block; + margin: 0 auto; + } + + .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); + } + + .signers-list { + display: flex; + flex-direction: column; + gap: 3px; + max-height: 120px; + overflow-y: auto; + } + + .signer-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + padding: 3px 6px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.03); + color: var(--rs-text-secondary, #94a3b8); + } + + .signer-item.signed { + color: #22c55e; + } + + .signer-icon { + width: 14px; + text-align: center; + font-size: 10px; + } + + .signer-name { + flex: 1; + } + + .signer-toggle { + 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: 10px; + padding: 1px 6px; + cursor: pointer; + } + + .signer-toggle:hover { + background: rgba(255, 255, 255, 0.12); + } + + .add-signer-row { + display: flex; + gap: 4px; + } + + .add-signer-input { + flex: 1; + 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; + } + + .add-signer-btn { + background: ${HEADER_COLOR}; + border: none; + color: white; + border-radius: 4px; + padding: 3px 8px; + font-size: 11px; + cursor: pointer; + font-weight: 600; + } + + .add-signer-btn:hover { + opacity: 0.85; + } + + .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-multisig": FolkGovMultisig; + } +} + +export class FolkGovMultisig extends FolkShape { + static override tagName = "folk-gov-multisig"; + + static override portDescriptors: PortDescriptor[] = [ + { name: "signer-in", type: "json", 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 = "Multisig"; + #requiredM = 2; + #signers: Signer[] = []; + + // DOM refs + #titleEl!: HTMLInputElement; + #mEl!: HTMLInputElement; + #nEl!: HTMLElement; + #muxEl!: HTMLElement; + #progressBar!: HTMLElement; + #signersList!: HTMLElement; + #addInput!: HTMLInputElement; + #statusEl!: HTMLElement; + + get title() { return this.#title; } + set title(v: string) { + this.#title = v; + if (this.#titleEl) this.#titleEl.value = v; + } + + get requiredM() { return this.#requiredM; } + set requiredM(v: number) { + this.#requiredM = v; + if (this.#mEl) this.#mEl.value = String(v); + this.#updateVisuals(); + this.#emitPort(); + } + + get signers(): Signer[] { return [...this.#signers]; } + set signers(v: Signer[]) { + this.#signers = v; + this.#updateVisuals(); + this.#emitPort(); + } + + get #signedCount(): number { + return this.#signers.filter(s => s.signed).length; + } + + get #isSatisfied(): boolean { + return this.#signedCount >= this.#requiredM; + } + + 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` +
+ 🔐 Multisig + + + +
+
+ +
+ + of + 0 + required +
+
+
+
+
+
+
+ + +
+ 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.#mEl = wrapper.querySelector(".mn-m-input") as HTMLInputElement; + this.#nEl = wrapper.querySelector(".mn-n-label") as HTMLElement; + this.#muxEl = wrapper.querySelector(".mux-svg") as HTMLElement; + this.#progressBar = wrapper.querySelector(".progress-bar") as HTMLElement; + this.#signersList = wrapper.querySelector(".signers-list") as HTMLElement; + this.#addInput = wrapper.querySelector(".add-signer-input") as HTMLInputElement; + this.#statusEl = wrapper.querySelector(".status-label") as HTMLElement; + + // Set initial values + this.#titleEl.value = this.#title; + this.#mEl.value = String(this.#requiredM); + this.#updateVisuals(); + + // Wire events + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#mEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#requiredM = Math.max(1, parseInt(this.#mEl.value) || 1); + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + wrapper.querySelector(".add-signer-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + const name = this.#addInput.value.trim(); + if (!name) return; + if (this.#signers.some(s => s.name === name)) return; + this.#signers.push({ name, signed: false, timestamp: 0 }); + this.#addInput.value = ""; + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#addInput.addEventListener("keydown", (e) => { + e.stopPropagation(); + if (e.key === "Enter") { + wrapper.querySelector(".add-signer-btn")!.dispatchEvent(new Event("click")); + } + }); + + 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 port + this.addEventListener("port-value-changed", ((e: CustomEvent) => { + const { name, value } = e.detail; + if (name === "signer-in" && value && typeof value === "object") { + const v = value as any; + const signerName = v.signedBy || v.who || v.name || ""; + const isSatisfied = v.satisfied === true; + if (signerName && isSatisfied) { + const existing = this.#signers.find(s => s.name === signerName); + if (existing) { + existing.signed = true; + existing.timestamp = v.timestamp || Date.now(); + } else { + this.#signers.push({ name: signerName, signed: true, timestamp: v.timestamp || Date.now() }); + } + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + } + } + }) as EventListener); + + return root; + } + + #updateVisuals() { + const n = this.#signers.length; + const signed = this.#signedCount; + const satisfied = this.#isSatisfied; + const pct = n > 0 ? (signed / Math.max(this.#requiredM, 1)) * 100 : 0; + + if (this.#nEl) this.#nEl.textContent = String(n); + + if (this.#progressBar) { + this.#progressBar.style.width = `${Math.min(100, pct)}%`; + this.#progressBar.classList.toggle("complete", satisfied); + } + + if (this.#statusEl) { + this.#statusEl.textContent = satisfied ? "SATISFIED" : "WAITING"; + this.#statusEl.className = `status-label ${satisfied ? "satisfied" : "waiting"}`; + } + + this.#renderMux(); + this.#renderSigners(); + } + + #renderMux() { + if (!this.#muxEl) return; + const n = this.#signers.length; + if (n === 0) { + this.#muxEl.innerHTML = ""; + return; + } + + const W = 180; + const slotH = 14; + const gateW = 30; + const gateH = Math.max(20, n * slotH + 4); + const H = gateH + 16; + const gateX = W / 2 - gateW / 2; + const gateY = (H - gateH) / 2; + + let svg = ``; + + // Gate body + svg += ``; + svg += `${this.#requiredM}/${n}`; + + // Input lines (left side) + for (let i = 0; i < n; i++) { + const y = gateY + 2 + slotH * i + slotH / 2; + const signed = this.#signers[i].signed; + const color = signed ? "#22c55e" : "rgba(255,255,255,0.2)"; + svg += ``; + svg += ``; + } + + // Output line (right side) + const outY = gateY + gateH / 2; + const outColor = this.#isSatisfied ? "#22c55e" : "rgba(255,255,255,0.2)"; + svg += ``; + svg += ``; + + svg += ""; + this.#muxEl.innerHTML = svg; + } + + #renderSigners() { + if (!this.#signersList) return; + this.#signersList.innerHTML = this.#signers.map((s, i) => { + const icon = s.signed ? "✓" : "○"; + const cls = s.signed ? "signer-item signed" : "signer-item"; + const btnLabel = s.signed ? "unsign" : "sign"; + return `
+ ${icon} + ${this.#escapeHtml(s.name)} + +
`; + }).join(""); + + // Wire toggle buttons + this.#signersList.querySelectorAll(".signer-toggle").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const idx = parseInt((btn as HTMLElement).dataset.idx!); + const signer = this.#signers[idx]; + signer.signed = !signer.signed; + signer.timestamp = signer.signed ? Date.now() : 0; + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + btn.addEventListener("pointerdown", (e) => e.stopPropagation()); + }); + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + #emitPort() { + this.setPortValue("gate-out", { + satisfied: this.#isSatisfied, + signed: this.#signedCount, + required: this.#requiredM, + total: this.#signers.length, + signers: this.#signers.filter(s => s.signed).map(s => s.name), + }); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-multisig", + title: this.#title, + requiredM: this.#requiredM, + signers: this.#signers, + }; + } + + static override fromData(data: Record): FolkGovMultisig { + const shape = FolkShape.fromData.call(this, data) as FolkGovMultisig; + if (data.title !== undefined) shape.title = data.title; + if (data.requiredM !== undefined) shape.requiredM = data.requiredM; + if (data.signers !== undefined) shape.signers = data.signers; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + if (data.requiredM !== undefined && data.requiredM !== this.#requiredM) this.requiredM = data.requiredM; + if (data.signers !== undefined && JSON.stringify(data.signers) !== JSON.stringify(this.#signers)) this.signers = data.signers; + } +} diff --git a/lib/folk-gov-project.ts b/lib/folk-gov-project.ts index 1e14966..e12ca04 100644 --- a/lib/folk-gov-project.ts +++ b/lib/folk-gov-project.ts @@ -16,6 +16,9 @@ const GOV_TAG_NAMES = new Set([ "FOLK-GOV-THRESHOLD", "FOLK-GOV-KNOB", "FOLK-GOV-AMENDMENT", + "FOLK-GOV-QUADRATIC", + "FOLK-GOV-CONVICTION", + "FOLK-GOV-MULTISIG", ]); type ProjectStatus = "draft" | "active" | "completed" | "archived"; diff --git a/lib/folk-gov-quadratic.ts b/lib/folk-gov-quadratic.ts new file mode 100644 index 0000000..81c8a2b --- /dev/null +++ b/lib/folk-gov-quadratic.ts @@ -0,0 +1,409 @@ +/** + * folk-gov-quadratic — Weight Transformer + * + * Inline weight transform GovMod. Accepts raw weight on input port, + * applies sqrt/log/linear transform, and emits effective weight on output. + * Always passes (gate-out = satisfied). Visualizes raw vs effective in a bar chart. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; +import type { PortDescriptor } from "./data-types"; + +const HEADER_COLOR = "#14b8a6"; + +type TransformMode = "sqrt" | "log" | "linear"; + +interface WeightEntry { + who: string; + raw: number; + effective: 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: 240px; + min-height: 140px; + 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); + } + + .mode-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + } + + .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: 2px 6px; + outline: none; + } + + .chart-area { + min-height: 60px; + } + + .chart-area svg { + width: 100%; + display: block; + } + + .entries-list { + max-height: 80px; + overflow-y: auto; + font-size: 10px; + color: var(--rs-text-muted, #94a3b8); + } + + .entry-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; + color: #22c55e; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-gov-quadratic": FolkGovQuadratic; + } +} + +export class FolkGovQuadratic extends FolkShape { + static override tagName = "folk-gov-quadratic"; + + static override portDescriptors: PortDescriptor[] = [ + { name: "weight-in", type: "json", direction: "input" }, + { name: "weight-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 = "Weight Transform"; + #mode: TransformMode = "sqrt"; + #entries: WeightEntry[] = []; + + // DOM refs + #titleEl!: HTMLInputElement; + #modeEl!: HTMLSelectElement; + #chartEl!: HTMLElement; + #listEl!: HTMLElement; + + get title() { return this.#title; } + set title(v: string) { + this.#title = v; + if (this.#titleEl) this.#titleEl.value = v; + } + + get mode() { return this.#mode; } + set mode(v: TransformMode) { + this.#mode = v; + if (this.#modeEl) this.#modeEl.value = v; + this.#recalc(); + } + + get entries(): WeightEntry[] { return [...this.#entries]; } + set entries(v: WeightEntry[]) { + this.#entries = v; + this.#updateVisuals(); + this.#emitPorts(); + } + + #transform(raw: number): number { + if (raw <= 0) return 0; + switch (this.#mode) { + case "sqrt": return Math.sqrt(raw); + case "log": return Math.log1p(raw); + case "linear": return raw; + } + } + + #recalc() { + for (const e of this.#entries) { + e.effective = this.#transform(e.raw); + } + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + 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` +
+ √ Quadratic + + + +
+
+ +
+ Mode: + +
+
+
+ PASSTHROUGH +
+ `; + + 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.#modeEl = wrapper.querySelector(".mode-select") as HTMLSelectElement; + this.#chartEl = wrapper.querySelector(".chart-area") as HTMLElement; + this.#listEl = wrapper.querySelector(".entries-list") as HTMLElement; + + // Set initial values + this.#titleEl.value = this.#title; + this.#modeEl.value = this.#mode; + this.#updateVisuals(); + + // Wire events + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#modeEl.addEventListener("change", (e) => { + e.stopPropagation(); + this.#mode = this.#modeEl.value as TransformMode; + this.#recalc(); + }); + + 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, select, button")) { + el.addEventListener("pointerdown", (e) => e.stopPropagation()); + } + + // Handle input port + this.addEventListener("port-value-changed", ((e: CustomEvent) => { + const { name, value } = e.detail; + if (name === "weight-in" && value && typeof value === "object") { + const v = value as any; + // Accept { who, weight } or { who, raw } + const who = v.who || v.memberName || "anonymous"; + const raw = v.weight || v.raw || v.amount || 0; + // Update or add + const existing = this.#entries.find(e => e.who === who); + if (existing) { + existing.raw = raw; + existing.effective = this.#transform(raw); + } else { + this.#entries.push({ who, raw, effective: this.#transform(raw) }); + } + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + } + }) as EventListener); + + return root; + } + + #updateVisuals() { + this.#renderChart(); + this.#renderList(); + } + + #renderChart() { + if (!this.#chartEl) return; + if (this.#entries.length === 0) { + this.#chartEl.innerHTML = ""; + return; + } + + const W = 220; + const H = 70; + const PAD = { top: 6, right: 8, bottom: 16, left: 8 }; + const plotW = W - PAD.left - PAD.right; + const plotH = H - PAD.top - PAD.bottom; + + const maxRaw = Math.max(1, ...this.#entries.map(e => e.raw)); + const maxEff = Math.max(1, ...this.#entries.map(e => e.effective)); + const maxVal = Math.max(maxRaw, maxEff); + const barW = Math.max(6, Math.min(20, plotW / (this.#entries.length * 2.5))); + + let svg = ``; + + // Grid line + svg += ``; + + const entries = this.#entries.slice(0, 8); // max 8 bars + const groupW = plotW / entries.length; + + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + const cx = PAD.left + groupW * i + groupW / 2; + const rawH = (e.raw / maxVal) * plotH; + const effH = (e.effective / maxVal) * plotH; + + // Raw bar (dimmed) + svg += ``; + // Effective bar (teal) + svg += ``; + + // Label + const label = e.who.length > 5 ? e.who.slice(0, 5) : e.who; + svg += `${label}`; + } + + // Legend + svg += ``; + svg += `raw`; + svg += ``; + svg += `eff`; + + svg += ""; + this.#chartEl.innerHTML = svg; + } + + #renderList() { + if (!this.#listEl) return; + this.#listEl.innerHTML = this.#entries.map(e => + `
${e.who}${e.raw.toFixed(1)} → ${e.effective.toFixed(2)}
` + ).join(""); + } + + #emitPorts() { + const totalRaw = this.#entries.reduce((s, e) => s + e.raw, 0); + const totalEffective = this.#entries.reduce((s, e) => s + e.effective, 0); + + this.setPortValue("weight-out", { + totalRaw, + totalEffective, + mode: this.#mode, + entries: this.#entries.map(e => ({ who: e.who, raw: e.raw, effective: e.effective })), + }); + + // Always satisfied — this is a passthrough transform + this.setPortValue("gate-out", { + satisfied: true, + totalRaw, + totalEffective, + mode: this.#mode, + }); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-quadratic", + title: this.#title, + mode: this.#mode, + entries: this.#entries, + }; + } + + static override fromData(data: Record): FolkGovQuadratic { + const shape = FolkShape.fromData.call(this, data) as FolkGovQuadratic; + if (data.title !== undefined) shape.title = data.title; + if (data.mode !== undefined) shape.mode = data.mode; + if (data.entries !== undefined) shape.entries = data.entries; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + if (data.mode !== undefined && data.mode !== this.#mode) this.mode = data.mode; + if (data.entries !== undefined && JSON.stringify(data.entries) !== JSON.stringify(this.#entries)) this.entries = data.entries; + } +} diff --git a/lib/folk-gov-sankey.ts b/lib/folk-gov-sankey.ts new file mode 100644 index 0000000..ee5ea91 --- /dev/null +++ b/lib/folk-gov-sankey.ts @@ -0,0 +1,512 @@ +/** + * folk-gov-sankey — Governance Flow Visualizer + * + * Auto-discovers all connected governance shapes via arrow graph traversal, + * renders an SVG Sankey diagram with animated flow curves, tooltips, and + * a color-coded legend. Purely visual — no ports. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const HEADER_COLOR = "#7c3aed"; + +// Gov shape tag names recognized by the visualizer +const GOV_TAG_NAMES = new Set([ + "FOLK-GOV-BINARY", + "FOLK-GOV-THRESHOLD", + "FOLK-GOV-KNOB", + "FOLK-GOV-PROJECT", + "FOLK-GOV-AMENDMENT", + "FOLK-GOV-QUADRATIC", + "FOLK-GOV-CONVICTION", + "FOLK-GOV-MULTISIG", +]); + +const TYPE_COLORS: Record = { + "FOLK-GOV-BINARY": "#7c3aed", + "FOLK-GOV-THRESHOLD": "#0891b2", + "FOLK-GOV-KNOB": "#b45309", + "FOLK-GOV-PROJECT": "#1d4ed8", + "FOLK-GOV-AMENDMENT": "#be185d", + "FOLK-GOV-QUADRATIC": "#14b8a6", + "FOLK-GOV-CONVICTION": "#d97706", + "FOLK-GOV-MULTISIG": "#6366f1", +}; + +const TYPE_LABELS: Record = { + "FOLK-GOV-BINARY": "Binary", + "FOLK-GOV-THRESHOLD": "Threshold", + "FOLK-GOV-KNOB": "Knob", + "FOLK-GOV-PROJECT": "Project", + "FOLK-GOV-AMENDMENT": "Amendment", + "FOLK-GOV-QUADRATIC": "Quadratic", + "FOLK-GOV-CONVICTION": "Conviction", + "FOLK-GOV-MULTISIG": "Multisig", +}; + +interface SankeyNode { + id: string; + tagName: string; + title: string; + satisfied: boolean; + column: number; // 0 = leftmost + row: number; +} + +interface SankeyFlow { + sourceId: string; + targetId: string; +} + +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: 340px; + min-height: 240px; + 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: 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); + } + + .summary { + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + text-align: center; + } + + .sankey-area svg { + width: 100%; + display: block; + } + + .legend { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + } + + .legend-item { + display: flex; + align-items: center; + gap: 4px; + font-size: 9px; + color: var(--rs-text-muted, #94a3b8); + } + + .legend-dot { + width: 8px; + height: 8px; + border-radius: 2px; + flex-shrink: 0; + } + + .no-data { + font-size: 11px; + color: var(--rs-text-muted, #475569); + font-style: italic; + text-align: center; + padding: 24px 0; + } + + @keyframes flow-dash { + to { stroke-dashoffset: -20; } + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-gov-sankey": FolkGovSankey; + } +} + +export class FolkGovSankey extends FolkShape { + static override tagName = "folk-gov-sankey"; + + 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 = "Governance Flow"; + #pollInterval: ReturnType | null = null; + #lastHash = ""; + + // DOM refs + #titleEl!: HTMLInputElement; + #summaryEl!: HTMLElement; + #sankeyEl!: HTMLElement; + #legendEl!: HTMLElement; + + get title() { return this.#title; } + set title(v: string) { + this.#title = v; + if (this.#titleEl) this.#titleEl.value = v; + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; + wrapper.innerHTML = html` +
+ 📊 Sankey + + + +
+
+ +
+
+
+
+ `; + + 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.#summaryEl = wrapper.querySelector(".summary") as HTMLElement; + this.#sankeyEl = wrapper.querySelector(".sankey-area") as HTMLElement; + this.#legendEl = wrapper.querySelector(".legend") as HTMLElement; + + // Set initial values + this.#titleEl.value = this.#title; + + // Wire events + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + 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()); + } + + // Poll every 3 seconds + this.#pollInterval = setInterval(() => this.#discover(), 3000); + requestAnimationFrame(() => this.#discover()); + + return root; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this.#pollInterval) { + clearInterval(this.#pollInterval); + this.#pollInterval = null; + } + } + + #discover() { + const arrows = document.querySelectorAll("folk-arrow"); + const nodes = new Map(); + const flows: SankeyFlow[] = []; + + // Collect all gov shapes connected by arrows + for (const arrow of arrows) { + const a = arrow as any; + const sourceId = a.sourceId; + const targetId = a.targetId; + if (!sourceId || !targetId) continue; + + // Skip self + if (sourceId === this.id || targetId === this.id) continue; + + const sourceEl = document.getElementById(sourceId) as any; + const targetEl = document.getElementById(targetId) as any; + if (!sourceEl || !targetEl) continue; + + const srcTag = sourceEl.tagName?.toUpperCase(); + const tgtTag = targetEl.tagName?.toUpperCase(); + + const srcIsGov = GOV_TAG_NAMES.has(srcTag); + const tgtIsGov = GOV_TAG_NAMES.has(tgtTag); + + if (!srcIsGov && !tgtIsGov) continue; + + if (srcIsGov && !nodes.has(sourceId)) { + const portVal = sourceEl.getPortValue?.("gate-out"); + nodes.set(sourceId, { + id: sourceId, + tagName: srcTag, + title: sourceEl.title || srcTag, + satisfied: portVal?.satisfied === true, + column: 0, + row: 0, + }); + } + + if (tgtIsGov && !nodes.has(targetId)) { + const portVal = targetEl.getPortValue?.("gate-out") || targetEl.getPortValue?.("circuit-out"); + nodes.set(targetId, { + id: targetId, + tagName: tgtTag, + title: targetEl.title || tgtTag, + satisfied: portVal?.satisfied === true || portVal?.status === "completed", + column: 0, + row: 0, + }); + } + + if (srcIsGov && tgtIsGov) { + flows.push({ sourceId, targetId }); + } + } + + // Hash-based skip + const hash = [...nodes.keys()].sort().join(",") + "|" + + flows.map(f => `${f.sourceId}->${f.targetId}`).sort().join(",") + + "|" + [...nodes.values()].map(n => n.satisfied ? "1" : "0").join(""); + if (hash === this.#lastHash) return; + this.#lastHash = hash; + + this.#layout(nodes, flows); + this.#renderSankey(nodes, flows); + } + + #layout(nodes: Map, flows: SankeyFlow[]) { + if (nodes.size === 0) return; + + // Build adjacency for topological column assignment + const outEdges = new Map(); + const inDegree = new Map(); + for (const n of nodes.keys()) { + outEdges.set(n, []); + inDegree.set(n, 0); + } + for (const f of flows) { + if (nodes.has(f.sourceId) && nodes.has(f.targetId)) { + outEdges.get(f.sourceId)!.push(f.targetId); + inDegree.set(f.targetId, (inDegree.get(f.targetId) || 0) + 1); + } + } + + // BFS topological layering + const queue: string[] = []; + for (const [id, deg] of inDegree) { + if (deg === 0) queue.push(id); + } + + const visited = new Set(); + while (queue.length > 0) { + const id = queue.shift()!; + if (visited.has(id)) continue; + visited.add(id); + + for (const next of outEdges.get(id) || []) { + const parentCol = nodes.get(id)!.column; + const node = nodes.get(next)!; + node.column = Math.max(node.column, parentCol + 1); + const newDeg = (inDegree.get(next) || 1) - 1; + inDegree.set(next, newDeg); + if (newDeg <= 0) queue.push(next); + } + } + + // Assign rows within each column + const columns = new Map(); + for (const [id, node] of nodes) { + const col = node.column; + if (!columns.has(col)) columns.set(col, []); + columns.get(col)!.push(id); + } + for (const [, ids] of columns) { + ids.forEach((id, i) => { + nodes.get(id)!.row = i; + }); + } + } + + #renderSankey(nodes: Map, flows: SankeyFlow[]) { + if (nodes.size === 0) { + if (this.#summaryEl) this.#summaryEl.textContent = ""; + if (this.#sankeyEl) this.#sankeyEl.innerHTML = `
Drop near gov shapes to visualize flows
`; + if (this.#legendEl) this.#legendEl.innerHTML = ""; + return; + } + + // Summary + if (this.#summaryEl) { + this.#summaryEl.textContent = `${nodes.size} shapes, ${flows.length} flows`; + } + + // Calculate dimensions + const maxCol = Math.max(...[...nodes.values()].map(n => n.column)); + const columns = new Map(); + for (const n of nodes.values()) { + if (!columns.has(n.column)) columns.set(n.column, []); + columns.get(n.column)!.push(n); + } + const maxRows = Math.max(...[...columns.values()].map(c => c.length)); + + const NODE_W = 80; + const NODE_H = 28; + const COL_GAP = 60; + const ROW_GAP = 12; + const PAD = 16; + + const W = PAD * 2 + (maxCol + 1) * NODE_W + maxCol * COL_GAP; + const H = PAD * 2 + maxRows * NODE_H + (maxRows - 1) * ROW_GAP; + + const nodeX = (col: number) => PAD + col * (NODE_W + COL_GAP); + const nodeY = (col: number, row: number) => { + const colNodes = columns.get(col) || []; + const totalH = colNodes.length * NODE_H + (colNodes.length - 1) * ROW_GAP; + const offsetY = (H - totalH) / 2; + return offsetY + row * (NODE_H + ROW_GAP); + }; + + let svg = ``; + + // Flows (Bezier curves) + for (const f of flows) { + const src = nodes.get(f.sourceId); + const tgt = nodes.get(f.targetId); + if (!src || !tgt) continue; + + const sx = nodeX(src.column) + NODE_W; + const sy = nodeY(src.column, src.row) + NODE_H / 2; + const tx = nodeX(tgt.column); + const ty = nodeY(tgt.column, tgt.row) + NODE_H / 2; + const cx1 = sx + (tx - sx) * 0.4; + const cx2 = tx - (tx - sx) * 0.4; + + const color = TYPE_COLORS[src.tagName] || "#94a3b8"; + + // Background curve + svg += ``; + // Animated dash curve + svg += ``; + } + + // Nodes + for (const n of nodes.values()) { + const x = nodeX(n.column); + const y = nodeY(n.column, n.row); + const color = TYPE_COLORS[n.tagName] || "#94a3b8"; + const fillOpacity = n.satisfied ? "0.25" : "0.1"; + + svg += ``; + + // Satisfied glow + if (n.satisfied) { + svg += ``; + } + + // Label (truncated) + const label = n.title.length > 12 ? n.title.slice(0, 11) + "..." : n.title; + svg += `${this.#escapeXml(label)}`; + + // Tooltip title + svg += `${this.#escapeXml(n.title)} (${TYPE_LABELS[n.tagName] || n.tagName}) - ${n.satisfied ? "Satisfied" : "Waiting"}`; + } + + svg += ""; + if (this.#sankeyEl) this.#sankeyEl.innerHTML = svg; + + // Legend + if (this.#legendEl) { + const usedTypes = new Set([...nodes.values()].map(n => n.tagName)); + this.#legendEl.innerHTML = [...usedTypes].map(t => { + const color = TYPE_COLORS[t] || "#94a3b8"; + const label = TYPE_LABELS[t] || t; + return `
${label}
`; + }).join(""); + } + } + + #escapeXml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-sankey", + title: this.#title, + }; + } + + static override fromData(data: Record): FolkGovSankey { + const shape = FolkShape.fromData.call(this, data) as FolkGovSankey; + if (data.title !== undefined) shape.title = data.title; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + } +} diff --git a/lib/index.ts b/lib/index.ts index d293d0e..8aa4d07 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -88,6 +88,10 @@ export * from "./folk-gov-threshold"; export * from "./folk-gov-knob"; export * from "./folk-gov-project"; export * from "./folk-gov-amendment"; +export * from "./folk-gov-quadratic"; +export * from "./folk-gov-conviction"; +export * from "./folk-gov-multisig"; +export * from "./folk-gov-sankey"; // Decision/Choice Shapes export * from "./folk-choice-vote"; diff --git a/modules/rgov/landing.ts b/modules/rgov/landing.ts index 79de05f..62d2973 100644 --- a/modules/rgov/landing.ts +++ b/modules/rgov/landing.ts @@ -255,6 +255,84 @@ export function renderLanding(): string { + +
+
+
+ ADVANCED GOVMODS +

+ Delegated Democracy & Flow Visualization +

+

+ Beyond simple gates: weight transformation for fair voting, time-weighted conviction, + multi-party approval, and real-time governance flow visualization. +

+
+ +
+ +
+
+
+ +
+

Quadratic Transform

+
+

+ Inline weight dampening. Raw votes pass through sqrt, log, or linear transforms — + reducing whale dominance while preserving signal. Bar chart shows raw vs effective. + Fair voting by default. +

+
+ + +
+
+
+ +
+

Conviction Accumulator

+
+

+ Time-weighted conviction scoring. Stakes accumulate conviction over hours — longer + commitment means stronger signal. Gate mode triggers at threshold; tuner mode streams live score. + Decisions that reward patience. +

+
+ + +
+
+
+ 🔐 +
+

Multisig Gate

+
+

+ M-of-N approval multiplexor. Name your signers, require 3 of 5 (or any ratio). + Multiplexor SVG shows inbound approval lines converging through a gate symbol. + Council-grade approval on the canvas. +

+
+ + +
+
+
+ 📊 +
+

Sankey Visualizer

+
+

+ Drop a Sankey shape near your circuit and it auto-discovers all connected gov shapes. + Animated Bezier flow curves, color-coded nodes, and tooltips. See your governance at a glance. + Governance you can see flowing. +

+
+
+
+
+
diff --git a/modules/rgov/mod.ts b/modules/rgov/mod.ts index e0b2910..8320c08 100644 --- a/modules/rgov/mod.ts +++ b/modules/rgov/mod.ts @@ -40,6 +40,10 @@ routes.get("/", (c) => {
  • Knobs — Tunable parameters with temporal viscosity
  • Projects — Circuit aggregators showing "X of Y gates satisfied"
  • Amendments — Propose in-place circuit modifications
  • +
  • Quadratic Transform — Weight dampening (sqrt/log) for fair voting
  • +
  • Conviction Accumulator — Time-weighted conviction scoring
  • +
  • Multisig Gate — M-of-N approval multiplexor
  • +
  • Sankey Visualizer — Auto-discovered governance flow diagram
  • Open Canvas → @@ -60,6 +64,10 @@ routes.get("/api/shapes", (c) => { "folk-gov-knob", "folk-gov-project", "folk-gov-amendment", + "folk-gov-quadratic", + "folk-gov-conviction", + "folk-gov-multisig", + "folk-gov-sankey", ], }); }); @@ -71,6 +79,7 @@ function seedTemplateGov(space: string) { const govTypes = [ "folk-gov-binary", "folk-gov-threshold", "folk-gov-knob", "folk-gov-project", "folk-gov-amendment", + "folk-gov-quadratic", "folk-gov-conviction", "folk-gov-multisig", "folk-gov-sankey", ]; if (docData?.shapes) { const existing = Object.values(docData.shapes as Record) @@ -192,8 +201,85 @@ function seedTemplateGov(space: string) { }, ]; + // ── Circuit 3: "Delegated Budget Approval" ── + // Quadratic transform → Conviction gate, plus 3-of-5 Multisig → Project, plus Sankey visualizer + const quadId = `gov-quad-${now}`; + const convId = `gov-conv-${now}`; + const msigId = `gov-msig-${now}`; + const budgetProjId = `gov-budgetproj-${now}`; + const sankeyId = `gov-sankey-${now}`; + + const c3BaseY = baseY + 700; + + shapes.push( + // Quadratic weight transform + { + id: quadId, type: "folk-gov-quadratic", + x: 1600, y: c3BaseY, width: 240, height: 160, rotation: 0, + title: "Vote Weight Dampener", mode: "sqrt", + entries: [ + { who: "Whale", raw: 100, effective: 10 }, + { who: "Alice", raw: 4, effective: 2 }, + { who: "Bob", raw: 1, effective: 1 }, + ], + }, + // Conviction accumulator + { + id: convId, type: "folk-gov-conviction", + x: 1600, y: c3BaseY + 200, width: 240, height: 200, rotation: 0, + title: "Community Support", convictionMode: "gate", threshold: 5, + stakes: [ + { userId: "u1", userName: "Alice", optionId: "gate", weight: 2, since: now - 7200000 }, + { userId: "u2", userName: "Bob", optionId: "gate", weight: 1, since: now - 3600000 }, + ], + }, + // Multisig 3-of-5 + { + id: msigId, type: "folk-gov-multisig", + x: 1600, y: c3BaseY + 440, width: 260, height: 220, rotation: 0, + title: "Council Approval", requiredM: 3, + signers: [ + { name: "Alice", signed: true, timestamp: now - 86400000 }, + { name: "Bob", signed: true, timestamp: now - 43200000 }, + { name: "Carol", signed: false, timestamp: 0 }, + { name: "Dave", signed: false, timestamp: 0 }, + { name: "Eve", signed: false, timestamp: 0 }, + ], + }, + // Project aggregator + { + id: budgetProjId, type: "folk-gov-project", + x: 1960, y: c3BaseY + 180, width: 300, height: 240, rotation: 0, + title: "Delegated Budget Approval", + description: "Budget approval with quadratic dampening, time-weighted conviction, and council multisig.", + status: "active", + }, + // Sankey visualizer + { + id: sankeyId, type: "folk-gov-sankey", + x: 2320, y: c3BaseY + 100, width: 380, height: 300, rotation: 0, + title: "Governance Flow", + }, + // Arrows wiring Circuit 3 + { + id: `gov-arrow-quad-${now}`, type: "folk-arrow", + x: 0, y: 0, width: 0, height: 0, rotation: 0, + sourceId: quadId, targetId: budgetProjId, color: "#14b8a6", + }, + { + id: `gov-arrow-conv-${now}`, type: "folk-arrow", + x: 0, y: 0, width: 0, height: 0, rotation: 0, + sourceId: convId, targetId: budgetProjId, color: "#d97706", + }, + { + id: `gov-arrow-msig-${now}`, type: "folk-arrow", + x: 0, y: 0, width: 0, height: 0, rotation: 0, + sourceId: msigId, targetId: budgetProjId, color: "#6366f1", + }, + ); + addShapes(space, shapes); - console.log(`[rGov] Template seeded for "${space}": 2 circuits (8 shapes + 6 arrows)`); + console.log(`[rGov] Template seeded for "${space}": 3 circuits (13 shapes + 9 arrows)`); } // ── Module export ── @@ -213,6 +299,10 @@ export const govModule: RSpaceModule = { "folk-gov-knob", "folk-gov-project", "folk-gov-amendment", + "folk-gov-quadratic", + "folk-gov-conviction", + "folk-gov-multisig", + "folk-gov-sankey", ], canvasToolIds: [ "create_binary_gate", @@ -220,6 +310,10 @@ export const govModule: RSpaceModule = { "create_gov_knob", "create_gov_project", "create_amendment", + "create_quadratic_transform", + "create_conviction_gate", + "create_multisig_gate", + "create_sankey_visualizer", ], onboardingActions: [ { label: "Build a Circuit", icon: "⚖️", description: "Create a governance decision circuit on the canvas", type: 'create', href: '/rgov' },