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:
parent
5f2b3fd8d1
commit
b3d6f2eba8
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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">⏳ Conviction</span>
|
||||
<span class="header-actions">
|
||||
<button class="close-btn">×</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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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">🔐 Multisig</span>
|
||||
<span class="header-actions">
|
||||
<button class="close-btn">×</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 ? "✓" : "○";
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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">×</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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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">📊 Sankey</span>
|
||||
<span class="header-actions">
|
||||
<button class="close-btn">×</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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 & 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">√</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 —
|
||||
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">⏳</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 — 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">🔐</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">📊</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">
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Reference in New Issue