feat(rgov): add quadratic, conviction, multisig & sankey GovMods

Four new governance circuit shapes for delegated democracy:
- folk-gov-quadratic: weight transformer (sqrt/log/linear dampening)
- folk-gov-conviction: time-weighted conviction accumulator (gate/tuner modes)
- folk-gov-multisig: M-of-N multiplexor gate with signer management
- folk-gov-sankey: auto-discovered governance flow visualizer with animated SVG

Registered in canvas-tools (4 AI tool declarations), index exports,
mod.ts (shapes, tools, types, seed Circuit 3), folk-gov-project
(recognizes new types), and landing page (Advanced GovMods section).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-03 17:03:39 -07:00
parent 5f2b3fd8d1
commit b3d6f2eba8
9 changed files with 2346 additions and 1 deletions

View File

@ -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];

606
lib/folk-gov-conviction.ts Normal file
View File

@ -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<typeof setInterval> | 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`
<div class="header" data-drag>
<span class="header-title">&#x23F3; Conviction</span>
<span class="header-actions">
<button class="close-btn">&times;</button>
</span>
</div>
<div class="body">
<input class="title-input" type="text" placeholder="Conviction gate title..." />
<div class="config-row">
<span>Mode:</span>
<select class="mode-select">
<option value="gate">Gate</option>
<option value="tuner">Tuner</option>
</select>
<span class="threshold-row">
<span>Threshold:</span>
<input class="threshold-input" type="number" min="0" step="1" />
</span>
</div>
<div class="progress-wrap">
<div class="progress-bar" style="width: 0%"></div>
<div class="progress-label">0 / 10</div>
</div>
<div class="score-display" style="display:none">0.00</div>
<div class="velocity-label"></div>
<div class="chart-area"></div>
<div class="stakes-list"></div>
<span class="status-label waiting">WAITING</span>
</div>
`;
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 = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">`;
// Threshold line in gate mode
if (this.#convictionMode === "gate" && this.#threshold > 0 && this.#threshold <= maxV) {
const ty = y(this.#threshold);
svg += `<line x1="${PAD.left}" y1="${ty}" x2="${W - PAD.right}" y2="${ty}" stroke="#22c55e" stroke-width="0.5" stroke-dasharray="3,2"/>`;
}
// 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 += `<path d="${areaD}" fill="${HEADER_COLOR}" opacity="0.15"/>`;
// Line
const lineD = points.map((p, i) => `${i === 0 ? "M" : "L"}${x(p.t)},${y(p.v)}`).join(" ");
svg += `<path d="${lineD}" fill="none" stroke="${HEADER_COLOR}" stroke-width="1.5" stroke-linejoin="round"/>`;
// End dot
const last = points[points.length - 1];
svg += `<circle cx="${x(last.t)}" cy="${y(last.v)}" r="2.5" fill="${HEADER_COLOR}"/>`;
// Y axis
svg += `<text x="${PAD.left - 3}" y="${PAD.top + 4}" text-anchor="end" font-size="7" fill="#94a3b8" font-family="system-ui">${this.#fmtScore(maxV)}</text>`;
svg += `<text x="${PAD.left - 3}" y="${y(0)}" text-anchor="end" font-size="7" fill="#94a3b8" font-family="system-ui">0</text>`;
svg += "</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 `<div class="stake-item"><span>${s.userName} (wt:${s.weight})</span><span>${dur}</span></div>`;
}).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<string, any>): 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<string, any>): 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;
}
}

549
lib/folk-gov-multisig.ts Normal file
View File

@ -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`
<div class="header" data-drag>
<span class="header-title">&#x1F510; Multisig</span>
<span class="header-actions">
<button class="close-btn">&times;</button>
</span>
</div>
<div class="body">
<input class="title-input" type="text" placeholder="Multisig title..." />
<div class="mn-row">
<input class="mn-m-input mn-input" type="number" min="1" />
<span>of</span>
<span class="mn-n-label">0</span>
<span>required</span>
</div>
<div class="mux-svg"></div>
<div class="progress-wrap">
<div class="progress-bar" style="width: 0%"></div>
</div>
<div class="signers-list"></div>
<div class="add-signer-row">
<input class="add-signer-input" type="text" placeholder="Add signer..." />
<button class="add-signer-btn">+</button>
</div>
<span class="status-label waiting">WAITING</span>
</div>
`;
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 = `<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`;
// Gate body
svg += `<rect x="${gateX}" y="${gateY}" width="${gateW}" height="${gateH}" rx="4" fill="rgba(99,102,241,0.15)" stroke="${HEADER_COLOR}" stroke-width="1.5"/>`;
svg += `<text x="${W / 2}" y="${gateY + gateH / 2 + 3}" text-anchor="middle" font-size="8" fill="${HEADER_COLOR}" font-weight="600" font-family="system-ui">${this.#requiredM}/${n}</text>`;
// 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 += `<line x1="10" y1="${y}" x2="${gateX}" y2="${y}" stroke="${color}" stroke-width="1.5"/>`;
svg += `<circle cx="10" cy="${y}" r="3" fill="${color}"/>`;
}
// Output line (right side)
const outY = gateY + gateH / 2;
const outColor = this.#isSatisfied ? "#22c55e" : "rgba(255,255,255,0.2)";
svg += `<line x1="${gateX + gateW}" y1="${outY}" x2="${W - 10}" y2="${outY}" stroke="${outColor}" stroke-width="1.5"/>`;
svg += `<polygon points="${W - 10},${outY - 4} ${W - 2},${outY} ${W - 10},${outY + 4}" fill="${outColor}"/>`;
svg += "</svg>";
this.#muxEl.innerHTML = svg;
}
#renderSigners() {
if (!this.#signersList) return;
this.#signersList.innerHTML = this.#signers.map((s, i) => {
const icon = s.signed ? "&#x2713;" : "&#x25CB;";
const cls = s.signed ? "signer-item signed" : "signer-item";
const btnLabel = s.signed ? "unsign" : "sign";
return `<div class="${cls}">
<span class="signer-icon">${icon}</span>
<span class="signer-name">${this.#escapeHtml(s.name)}</span>
<button class="signer-toggle" data-idx="${i}">${btnLabel}</button>
</div>`;
}).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<string, any>): 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<string, any>): 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;
}
}

View File

@ -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";

409
lib/folk-gov-quadratic.ts Normal file
View File

@ -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`
<div class="header" data-drag>
<span class="header-title"> Quadratic</span>
<span class="header-actions">
<button class="close-btn">&times;</button>
</span>
</div>
<div class="body">
<input class="title-input" type="text" placeholder="Transform title..." />
<div class="mode-row">
<span>Mode:</span>
<select class="mode-select">
<option value="sqrt"> Sqrt</option>
<option value="log">log(1+x)</option>
<option value="linear">Linear</option>
</select>
</div>
<div class="chart-area"></div>
<div class="entries-list"></div>
<span class="status-label">PASSTHROUGH</span>
</div>
`;
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 = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">`;
// Grid line
svg += `<line x1="${PAD.left}" y1="${PAD.top + plotH}" x2="${W - PAD.right}" y2="${PAD.top + plotH}" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/>`;
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 += `<rect x="${cx - barW - 1}" y="${PAD.top + plotH - rawH}" width="${barW}" height="${rawH}" rx="2" fill="rgba(255,255,255,0.15)"/>`;
// Effective bar (teal)
svg += `<rect x="${cx + 1}" y="${PAD.top + plotH - effH}" width="${barW}" height="${effH}" rx="2" fill="${HEADER_COLOR}"/>`;
// Label
const label = e.who.length > 5 ? e.who.slice(0, 5) : e.who;
svg += `<text x="${cx}" y="${H - 2}" text-anchor="middle" font-size="7" fill="#94a3b8" font-family="system-ui">${label}</text>`;
}
// Legend
svg += `<rect x="${W - 60}" y="2" width="6" height="6" rx="1" fill="rgba(255,255,255,0.15)"/>`;
svg += `<text x="${W - 52}" y="7.5" font-size="6" fill="#94a3b8" font-family="system-ui">raw</text>`;
svg += `<rect x="${W - 34}" y="2" width="6" height="6" rx="1" fill="${HEADER_COLOR}"/>`;
svg += `<text x="${W - 26}" y="7.5" font-size="6" fill="#94a3b8" font-family="system-ui">eff</text>`;
svg += "</svg>";
this.#chartEl.innerHTML = svg;
}
#renderList() {
if (!this.#listEl) return;
this.#listEl.innerHTML = this.#entries.map(e =>
`<div class="entry-item"><span>${e.who}</span><span>${e.raw.toFixed(1)}${e.effective.toFixed(2)}</span></div>`
).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<string, any>): 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<string, any>): 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;
}
}

512
lib/folk-gov-sankey.ts Normal file
View File

@ -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<string, string> = {
"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<string, string> = {
"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<typeof setInterval> | 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`
<div class="header" data-drag>
<span class="header-title">&#x1F4CA; Sankey</span>
<span class="header-actions">
<button class="close-btn">&times;</button>
</span>
</div>
<div class="body">
<input class="title-input" type="text" placeholder="Flow visualizer title..." />
<div class="summary"></div>
<div class="sankey-area"></div>
<div class="legend"></div>
</div>
`;
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<string, SankeyNode>();
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<string, SankeyNode>, flows: SankeyFlow[]) {
if (nodes.size === 0) return;
// Build adjacency for topological column assignment
const outEdges = new Map<string, string[]>();
const inDegree = new Map<string, number>();
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<string>();
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<number, string[]>();
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<string, SankeyNode>, flows: SankeyFlow[]) {
if (nodes.size === 0) {
if (this.#summaryEl) this.#summaryEl.textContent = "";
if (this.#sankeyEl) this.#sankeyEl.innerHTML = `<div class="no-data">Drop near gov shapes to visualize flows</div>`;
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<number, SankeyNode[]>();
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 = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">`;
// 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 += `<path d="M${sx},${sy} C${cx1},${sy} ${cx2},${ty} ${tx},${ty}" fill="none" stroke="${color}" stroke-width="3" opacity="0.15"/>`;
// Animated dash curve
svg += `<path d="M${sx},${sy} C${cx1},${sy} ${cx2},${ty} ${tx},${ty}" fill="none" stroke="${color}" stroke-width="2" stroke-dasharray="6,4" opacity="0.6" style="animation:flow-dash 1.5s linear infinite"/>`;
}
// 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 += `<rect x="${x}" y="${y}" width="${NODE_W}" height="${NODE_H}" rx="6" fill="${color}" fill-opacity="${fillOpacity}" stroke="${color}" stroke-width="1.5"/>`;
// Satisfied glow
if (n.satisfied) {
svg += `<rect x="${x}" y="${y}" width="${NODE_W}" height="${NODE_H}" rx="6" fill="none" stroke="#22c55e" stroke-width="1" opacity="0.5"/>`;
}
// Label (truncated)
const label = n.title.length > 12 ? n.title.slice(0, 11) + "..." : n.title;
svg += `<text x="${x + NODE_W / 2}" y="${y + NODE_H / 2 + 3}" text-anchor="middle" font-size="8" fill="${color}" font-weight="600" font-family="system-ui">${this.#escapeXml(label)}</text>`;
// Tooltip title
svg += `<title>${this.#escapeXml(n.title)} (${TYPE_LABELS[n.tagName] || n.tagName}) - ${n.satisfied ? "Satisfied" : "Waiting"}</title>`;
}
svg += "</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 `<div class="legend-item"><span class="legend-dot" style="background:${color}"></span>${label}</div>`;
}).join("");
}
}
#escapeXml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-gov-sankey",
title: this.#title,
};
}
static override fromData(data: Record<string, any>): FolkGovSankey {
const shape = FolkShape.fromData.call(this, data) as FolkGovSankey;
if (data.title !== undefined) shape.title = data.title;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.title !== undefined && data.title !== this.#title) this.title = data.title;
}
}

View File

@ -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";

View File

@ -255,6 +255,84 @@ export function renderLanding(): string {
</div>
</section>
<!-- Advanced GovMods -->
<section class="rl-section">
<div class="rl-container">
<div style="text-align:center;margin-bottom:2rem">
<span class="rl-badge" style="background:#1e293b;color:#94a3b8;font-size:0.7rem;padding:0.25rem 0.75rem">ADVANCED GOVMODS</span>
<h2 class="rl-heading" style="margin-top:0.75rem;background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Delegated Democracy &amp; Flow Visualization
</h2>
<p style="font-size:1.05rem;color:#94a3b8;max-width:680px;margin:0.5rem auto 0">
Beyond simple gates: weight transformation for fair voting, time-weighted conviction,
multi-party approval, and real-time governance flow visualization.
</p>
</div>
<div class="rl-grid-2" style="max-width:900px;margin:0 auto">
<!-- Quadratic Transform -->
<div class="rl-card" style="border:2px solid rgba(20,184,166,0.35);background:linear-gradient(to bottom right,rgba(20,184,166,0.08),rgba(20,184,166,0.03))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<div style="width:2rem;height:2rem;border-radius:9999px;background:#14b8a6;display:flex;align-items:center;justify-content:center">
<span style="color:white;font-weight:700;font-size:0.9rem">&radic;</span>
</div>
<h3 style="color:#2dd4bf;font-size:1.05rem;margin-bottom:0">Quadratic Transform</h3>
</div>
<p>
Inline weight dampening. Raw votes pass through sqrt, log, or linear transforms &mdash;
reducing whale dominance while preserving signal. Bar chart shows raw vs effective.
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">Fair voting by default.</strong>
</p>
</div>
<!-- Conviction Accumulator -->
<div class="rl-card" style="border:2px solid rgba(217,119,6,0.35);background:linear-gradient(to bottom right,rgba(217,119,6,0.08),rgba(217,119,6,0.03))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<div style="width:2rem;height:2rem;border-radius:9999px;background:#d97706;display:flex;align-items:center;justify-content:center">
<span style="color:white;font-size:1rem">&#x23F3;</span>
</div>
<h3 style="color:#fbbf24;font-size:1.05rem;margin-bottom:0">Conviction Accumulator</h3>
</div>
<p>
Time-weighted conviction scoring. Stakes accumulate conviction over hours &mdash; longer
commitment means stronger signal. Gate mode triggers at threshold; tuner mode streams live score.
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">Decisions that reward patience.</strong>
</p>
</div>
<!-- Multisig Gate -->
<div class="rl-card" style="border:2px solid rgba(99,102,241,0.35);background:linear-gradient(to bottom right,rgba(99,102,241,0.08),rgba(99,102,241,0.03))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<div style="width:2rem;height:2rem;border-radius:9999px;background:#6366f1;display:flex;align-items:center;justify-content:center">
<span style="color:white;font-size:1rem">&#x1F510;</span>
</div>
<h3 style="color:#818cf8;font-size:1.05rem;margin-bottom:0">Multisig Gate</h3>
</div>
<p>
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.
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">Council-grade approval on the canvas.</strong>
</p>
</div>
<!-- Sankey Visualizer -->
<div class="rl-card" style="border:2px solid rgba(124,58,237,0.35);background:linear-gradient(to bottom right,rgba(124,58,237,0.08),rgba(124,58,237,0.03))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<div style="width:2rem;height:2rem;border-radius:9999px;background:#7c3aed;display:flex;align-items:center;justify-content:center">
<span style="color:white;font-size:1rem">&#x1F4CA;</span>
</div>
<h3 style="color:#a78bfa;font-size:1.05rem;margin-bottom:0">Sankey Visualizer</h3>
</div>
<p>
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.
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">Governance you can see flowing.</strong>
</p>
</div>
</div>
</div>
</section>
<!-- Why Modular Governance -->
<section class="rl-section rl-section--alt">
<div class="rl-container">

View File

@ -40,6 +40,10 @@ routes.get("/", (c) => {
<li><strong>Knobs</strong> Tunable parameters with temporal viscosity</li>
<li><strong>Projects</strong> Circuit aggregators showing "X of Y gates satisfied"</li>
<li><strong>Amendments</strong> Propose in-place circuit modifications</li>
<li><strong>Quadratic Transform</strong> Weight dampening (sqrt/log) for fair voting</li>
<li><strong>Conviction Accumulator</strong> Time-weighted conviction scoring</li>
<li><strong>Multisig Gate</strong> M-of-N approval multiplexor</li>
<li><strong>Sankey Visualizer</strong> Auto-discovered governance flow diagram</li>
</ul>
<a href="/rspace" style="display:inline-block;background:linear-gradient(to right,#7c3aed,#1d4ed8);color:white;padding:10px 20px;border-radius:8px;text-decoration:none;font-weight:600;">
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<string, any>)
@ -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' },