/** * folk-gov-multisig — M-of-N Multiplexor Gate * * Requires M of N named signers before passing. Signers can be added * manually or auto-populated from upstream binary gates. Shows a * multiplexor SVG diagram and progress bar. */ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; import type { PortDescriptor } from "./data-types"; const HEADER_COLOR = "#6366f1"; interface Signer { name: string; signed: boolean; timestamp: number; } const styles = css` :host { background: var(--rs-bg-surface, #1e293b); border-radius: 10px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); min-width: 260px; min-height: 180px; overflow: hidden; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: ${HEADER_COLOR}; color: white; font-size: 12px; font-weight: 600; cursor: move; border-radius: 10px 10px 0 0; } .header-title { display: flex; align-items: center; gap: 6px; } .header-actions button { background: transparent; border: none; color: white; cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 14px; } .header-actions button:hover { background: rgba(255, 255, 255, 0.2); } .body { display: flex; flex-direction: column; padding: 12px; gap: 8px; } .title-input { background: transparent; border: none; color: var(--rs-text-primary, #e2e8f0); font-size: 13px; font-weight: 600; width: 100%; outline: none; } .title-input::placeholder { color: var(--rs-text-muted, #64748b); } .mn-row { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--rs-text-primary, #e2e8f0); font-weight: 600; } .mn-input { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; color: var(--rs-text-primary, #e2e8f0); font-size: 12px; padding: 2px 6px; width: 40px; text-align: center; outline: none; } .mux-svg { text-align: center; } .mux-svg svg { display: block; margin: 0 auto; } .progress-wrap { position: relative; height: 16px; background: rgba(255, 255, 255, 0.08); border-radius: 8px; overflow: hidden; } .progress-bar { height: 100%; border-radius: 8px; transition: width 0.3s, background 0.3s; background: ${HEADER_COLOR}; } .progress-bar.complete { background: #22c55e; box-shadow: 0 0 8px rgba(34, 197, 94, 0.4); } .signers-list { display: flex; flex-direction: column; gap: 3px; max-height: 120px; overflow-y: auto; } .signer-item { display: flex; align-items: center; gap: 6px; font-size: 11px; padding: 3px 6px; border-radius: 4px; background: rgba(255, 255, 255, 0.03); color: var(--rs-text-secondary, #94a3b8); } .signer-item.signed { color: #22c55e; } .signer-icon { width: 14px; text-align: center; font-size: 10px; } .signer-name { flex: 1; } .signer-toggle { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; color: var(--rs-text-primary, #e2e8f0); font-size: 10px; padding: 1px 6px; cursor: pointer; } .signer-toggle:hover { background: rgba(255, 255, 255, 0.12); } .add-signer-row { display: flex; gap: 4px; } .add-signer-input { flex: 1; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; color: var(--rs-text-primary, #e2e8f0); font-size: 11px; padding: 3px 6px; outline: none; } .add-signer-btn { background: ${HEADER_COLOR}; border: none; color: white; border-radius: 4px; padding: 3px 8px; font-size: 11px; cursor: pointer; font-weight: 600; } .add-signer-btn:hover { opacity: 0.85; } .status-label { font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; text-align: center; } .status-label.satisfied { color: #22c55e; } .status-label.waiting { color: #f59e0b; } `; declare global { interface HTMLElementTagNameMap { "folk-gov-multisig": FolkGovMultisig; } } export class FolkGovMultisig extends FolkShape { static override tagName = "folk-gov-multisig"; static override portDescriptors: PortDescriptor[] = [ { name: "signer-in", type: "json", direction: "input" }, { name: "gate-out", type: "json", direction: "output" }, ]; static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n"); const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n"); sheet.replaceSync(`${parentRules}\n${childRules}`); this.styles = sheet; } #title = "Multisig"; #requiredM = 2; #signers: Signer[] = []; // DOM refs #titleEl!: HTMLInputElement; #mEl!: HTMLInputElement; #nEl!: HTMLElement; #muxEl!: HTMLElement; #progressBar!: HTMLElement; #signersList!: HTMLElement; #addInput!: HTMLInputElement; #statusEl!: HTMLElement; get title() { return this.#title; } set title(v: string) { this.#title = v; if (this.#titleEl) this.#titleEl.value = v; } get requiredM() { return this.#requiredM; } set requiredM(v: number) { this.#requiredM = v; if (this.#mEl) this.#mEl.value = String(v); this.#updateVisuals(); this.#emitPort(); } get signers(): Signer[] { return [...this.#signers]; } set signers(v: Signer[]) { this.#signers = v; this.#updateVisuals(); this.#emitPort(); } get #signedCount(): number { return this.#signers.filter(s => s.signed).length; } get #isSatisfied(): boolean { return this.#signedCount >= this.#requiredM; } override createRenderRoot() { const root = super.createRenderRoot(); this.initPorts(); const wrapper = document.createElement("div"); wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; wrapper.innerHTML = html`
🔐 Multisig
of 0 required
WAITING
`; const slot = root.querySelector("slot"); const container = slot?.parentElement as HTMLElement; if (container) container.replaceWith(wrapper); // Cache refs this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; this.#mEl = wrapper.querySelector(".mn-m-input") as HTMLInputElement; this.#nEl = wrapper.querySelector(".mn-n-label") as HTMLElement; this.#muxEl = wrapper.querySelector(".mux-svg") as HTMLElement; this.#progressBar = wrapper.querySelector(".progress-bar") as HTMLElement; this.#signersList = wrapper.querySelector(".signers-list") as HTMLElement; this.#addInput = wrapper.querySelector(".add-signer-input") as HTMLInputElement; this.#statusEl = wrapper.querySelector(".status-label") as HTMLElement; // Set initial values this.#titleEl.value = this.#title; this.#mEl.value = String(this.#requiredM); this.#updateVisuals(); // Wire events this.#titleEl.addEventListener("input", (e) => { e.stopPropagation(); this.#title = this.#titleEl.value; this.dispatchEvent(new CustomEvent("content-change")); }); this.#mEl.addEventListener("input", (e) => { e.stopPropagation(); this.#requiredM = Math.max(1, parseInt(this.#mEl.value) || 1); this.#updateVisuals(); this.#emitPort(); this.dispatchEvent(new CustomEvent("content-change")); }); wrapper.querySelector(".add-signer-btn")!.addEventListener("click", (e) => { e.stopPropagation(); const name = this.#addInput.value.trim(); if (!name) return; if (this.#signers.some(s => s.name === name)) return; this.#signers.push({ name, signed: false, timestamp: 0 }); this.#addInput.value = ""; this.#updateVisuals(); this.#emitPort(); this.dispatchEvent(new CustomEvent("content-change")); }); this.#addInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") { wrapper.querySelector(".add-signer-btn")!.dispatchEvent(new Event("click")); } }); wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); // Prevent drag on inputs for (const el of wrapper.querySelectorAll("input, button")) { el.addEventListener("pointerdown", (e) => e.stopPropagation()); } // Handle input port this.addEventListener("port-value-changed", ((e: CustomEvent) => { const { name, value } = e.detail; if (name === "signer-in" && value && typeof value === "object") { const v = value as any; const signerName = v.signedBy || v.who || v.name || ""; const isSatisfied = v.satisfied === true; if (signerName && isSatisfied) { const existing = this.#signers.find(s => s.name === signerName); if (existing) { existing.signed = true; existing.timestamp = v.timestamp || Date.now(); } else { this.#signers.push({ name: signerName, signed: true, timestamp: v.timestamp || Date.now() }); } this.#updateVisuals(); this.#emitPort(); this.dispatchEvent(new CustomEvent("content-change")); } } }) as EventListener); return root; } #updateVisuals() { const n = this.#signers.length; const signed = this.#signedCount; const satisfied = this.#isSatisfied; const pct = n > 0 ? (signed / Math.max(this.#requiredM, 1)) * 100 : 0; if (this.#nEl) this.#nEl.textContent = String(n); if (this.#progressBar) { this.#progressBar.style.width = `${Math.min(100, pct)}%`; this.#progressBar.classList.toggle("complete", satisfied); } if (this.#statusEl) { this.#statusEl.textContent = satisfied ? "SATISFIED" : "WAITING"; this.#statusEl.className = `status-label ${satisfied ? "satisfied" : "waiting"}`; } this.#renderMux(); this.#renderSigners(); } #renderMux() { if (!this.#muxEl) return; const n = this.#signers.length; if (n === 0) { this.#muxEl.innerHTML = ""; return; } const W = 180; const slotH = 14; const gateW = 30; const gateH = Math.max(20, n * slotH + 4); const H = gateH + 16; const gateX = W / 2 - gateW / 2; const gateY = (H - gateH) / 2; let svg = ``; // Gate body svg += ``; svg += `${this.#requiredM}/${n}`; // Input lines (left side) for (let i = 0; i < n; i++) { const y = gateY + 2 + slotH * i + slotH / 2; const signed = this.#signers[i].signed; const color = signed ? "#22c55e" : "rgba(255,255,255,0.2)"; svg += ``; svg += ``; } // Output line (right side) const outY = gateY + gateH / 2; const outColor = this.#isSatisfied ? "#22c55e" : "rgba(255,255,255,0.2)"; svg += ``; svg += ``; svg += ""; this.#muxEl.innerHTML = svg; } #renderSigners() { if (!this.#signersList) return; this.#signersList.innerHTML = this.#signers.map((s, i) => { const icon = s.signed ? "✓" : "○"; const cls = s.signed ? "signer-item signed" : "signer-item"; const btnLabel = s.signed ? "unsign" : "sign"; return `
${icon} ${this.#escapeHtml(s.name)}
`; }).join(""); // Wire toggle buttons this.#signersList.querySelectorAll(".signer-toggle").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); const idx = parseInt((btn as HTMLElement).dataset.idx!); const signer = this.#signers[idx]; signer.signed = !signer.signed; signer.timestamp = signer.signed ? Date.now() : 0; this.#updateVisuals(); this.#emitPort(); this.dispatchEvent(new CustomEvent("content-change")); }); btn.addEventListener("pointerdown", (e) => e.stopPropagation()); }); } #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } #emitPort() { this.setPortValue("gate-out", { satisfied: this.#isSatisfied, signed: this.#signedCount, required: this.#requiredM, total: this.#signers.length, signers: this.#signers.filter(s => s.signed).map(s => s.name), }); } override toJSON() { return { ...super.toJSON(), type: "folk-gov-multisig", title: this.#title, requiredM: this.#requiredM, signers: this.#signers, }; } static override fromData(data: Record): FolkGovMultisig { const shape = FolkShape.fromData.call(this, data) as FolkGovMultisig; if (data.title !== undefined) shape.title = data.title; if (data.requiredM !== undefined) shape.requiredM = data.requiredM; if (data.signers !== undefined) shape.signers = data.signers; return shape; } override applyData(data: Record): void { super.applyData(data); if (data.title !== undefined && data.title !== this.#title) this.title = data.title; if (data.requiredM !== undefined && data.requiredM !== this.#requiredM) this.requiredM = data.requiredM; if (data.signers !== undefined && JSON.stringify(data.signers) !== JSON.stringify(this.#signers)) this.signers = data.signers; } }