/** * folk-gov-amendment — Circuit Modification Proposal * * References a target shape to modify, contains proposed replacement data. * Built-in approval mechanism (majority/unanimous/single) with inline voter list. * Shows a "Before → After" diff view. On approval, dispatches gov-amendment-apply * event to replace the target shape while preserving its ID. */ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; import type { PortDescriptor } from "./data-types"; const HEADER_COLOR = "#be185d"; type ApprovalMode = "single" | "majority" | "unanimous"; interface Vote { voter: string; approve: boolean; timestamp: number; } const styles = css` :host { background: var(--rs-bg-surface, #1e293b); border-radius: 10px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); min-width: 280px; min-height: 200px; overflow: hidden; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: ${HEADER_COLOR}; color: white; font-size: 12px; font-weight: 600; cursor: move; border-radius: 10px 10px 0 0; } .header-title { display: flex; align-items: center; gap: 6px; } .header-actions button { background: transparent; border: none; color: white; cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 14px; } .header-actions button:hover { background: rgba(255, 255, 255, 0.2); } .body { display: flex; flex-direction: column; padding: 12px; gap: 8px; overflow-y: auto; max-height: calc(100% - 36px); } .title-input { background: transparent; border: none; color: var(--rs-text-primary, #e2e8f0); font-size: 13px; font-weight: 600; width: 100%; outline: none; } .title-input::placeholder { color: var(--rs-text-muted, #64748b); } .field-row { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--rs-text-muted, #94a3b8); } .field-input { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; color: var(--rs-text-primary, #e2e8f0); font-size: 11px; padding: 3px 6px; outline: none; flex: 1; } .mode-select { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; color: var(--rs-text-primary, #e2e8f0); font-size: 11px; padding: 3px 6px; outline: none; } .diff-section { display: flex; flex-direction: column; gap: 4px; margin-top: 4px; } .diff-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--rs-text-muted, #64748b); } .diff-box { background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 4px; padding: 6px 8px; font-size: 10px; font-family: monospace; color: var(--rs-text-secondary, #94a3b8); max-height: 60px; overflow-y: auto; white-space: pre-wrap; } .diff-box.before { border-left: 3px solid #ef4444; } .diff-box.after { border-left: 3px solid #22c55e; } .diff-arrow { text-align: center; font-size: 14px; color: var(--rs-text-muted, #475569); } .desc-textarea { background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 4px; color: var(--rs-text-secondary, #cbd5e1); font-size: 11px; padding: 6px 8px; outline: none; resize: none; min-height: 36px; } .desc-textarea::placeholder { color: var(--rs-text-muted, #475569); } .voters-section { display: flex; flex-direction: column; gap: 4px; } .voter-row { display: flex; align-items: center; justify-content: space-between; font-size: 11px; padding: 3px 6px; border-radius: 4px; background: rgba(255, 255, 255, 0.03); } .voter-name { color: var(--rs-text-primary, #e2e8f0); } .vote-badge { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 3px; } .vote-badge.approve { background: rgba(34, 197, 94, 0.2); color: #22c55e; } .vote-badge.reject { background: rgba(239, 68, 68, 0.2); color: #ef4444; } .vote-actions { display: flex; gap: 4px; margin-top: 4px; } .vote-name-input { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; color: var(--rs-text-primary, #e2e8f0); font-size: 11px; padding: 3px 6px; outline: none; flex: 1; } .vote-btn { border: none; border-radius: 4px; padding: 3px 8px; font-size: 11px; font-weight: 600; cursor: pointer; } .vote-btn.approve { background: #22c55e; color: white; } .vote-btn.reject { background: #ef4444; color: white; } .vote-btn:hover { opacity: 0.85; } .apply-btn { background: ${HEADER_COLOR}; border: none; color: white; border-radius: 6px; padding: 6px 12px; font-size: 12px; font-weight: 600; cursor: pointer; text-align: center; margin-top: 4px; } .apply-btn:hover { opacity: 0.85; } .apply-btn:disabled { opacity: 0.4; cursor: not-allowed; } .status-badge { display: inline-block; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; padding: 2px 8px; border-radius: 4px; text-align: center; } .status-badge.pending { background: rgba(245, 158, 11, 0.2); color: #f59e0b; } .status-badge.approved { background: rgba(34, 197, 94, 0.2); color: #22c55e; } .status-badge.rejected { background: rgba(239, 68, 68, 0.2); color: #ef4444; } .status-badge.applied { background: rgba(29, 78, 216, 0.2); color: #60a5fa; } `; declare global { interface HTMLElementTagNameMap { "folk-gov-amendment": FolkGovAmendment; } } export class FolkGovAmendment extends FolkShape { static override tagName = "folk-gov-amendment"; static override portDescriptors: PortDescriptor[] = [ { name: "approval-in", type: "json", direction: "input" }, { name: "amendment-out", type: "json", direction: "output" }, { name: "gate-out", type: "json", direction: "output" }, ]; static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n"); const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n"); sheet.replaceSync(`${parentRules}\n${childRules}`); this.styles = sheet; } #title = "Amendment"; #targetShapeId = ""; #replacementType = "folk-gov-binary"; // what to replace with #replacementData: Record = {}; #approvalMode: ApprovalMode = "single"; #votes: Vote[] = []; #amendmentStatus: "pending" | "approved" | "rejected" | "applied" = "pending"; #description = ""; // DOM refs #titleEl!: HTMLInputElement; #targetEl!: HTMLInputElement; #replTypeEl!: HTMLInputElement; #modeEl!: HTMLSelectElement; #descEl!: HTMLTextAreaElement; #votersEl!: HTMLElement; #statusEl!: HTMLElement; #applyBtn!: HTMLButtonElement; #beforeBox!: HTMLElement; #voteNameEl!: HTMLInputElement; get title() { return this.#title; } set title(v: string) { this.#title = v; if (this.#titleEl) this.#titleEl.value = v; } get targetShapeId() { return this.#targetShapeId; } set targetShapeId(v: string) { this.#targetShapeId = v; if (this.#targetEl) this.#targetEl.value = v; this.#updateDiff(); } get replacementType() { return this.#replacementType; } set replacementType(v: string) { this.#replacementType = v; if (this.#replTypeEl) this.#replTypeEl.value = v; } get replacementData() { return this.#replacementData; } set replacementData(v: Record) { this.#replacementData = v; } get approvalMode(): ApprovalMode { return this.#approvalMode; } set approvalMode(v: ApprovalMode) { this.#approvalMode = v; if (this.#modeEl) this.#modeEl.value = v; } get votes() { return [...this.#votes]; } set votes(v: Vote[]) { this.#votes = v; this.#renderVoters(); this.#checkApproval(); } get amendmentStatus() { return this.#amendmentStatus; } set amendmentStatus(v: "pending" | "approved" | "rejected" | "applied") { this.#amendmentStatus = v; this.#updateStatusBadge(); } get description() { return this.#description; } set description(v: string) { this.#description = v; if (this.#descEl) this.#descEl.value = v; } override createRenderRoot() { const root = super.createRenderRoot(); this.initPorts(); const wrapper = document.createElement("div"); wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; wrapper.innerHTML = html`
📝 Amendment
Target:
Replace with:
Approval:
Before
After
PENDING
`; const slot = root.querySelector("slot"); const container = slot?.parentElement as HTMLElement; if (container) container.replaceWith(wrapper); // Cache refs this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; this.#targetEl = wrapper.querySelector(".target-id") as HTMLInputElement; this.#replTypeEl = wrapper.querySelector(".repl-type") as HTMLInputElement; this.#modeEl = wrapper.querySelector(".mode-select") as HTMLSelectElement; this.#descEl = wrapper.querySelector(".desc-textarea") as HTMLTextAreaElement; this.#votersEl = wrapper.querySelector(".voters-section") as HTMLElement; this.#statusEl = wrapper.querySelector(".status-badge") as HTMLElement; this.#applyBtn = wrapper.querySelector(".apply-btn") as HTMLButtonElement; this.#beforeBox = wrapper.querySelector(".diff-box.before") as HTMLElement; this.#voteNameEl = wrapper.querySelector(".vote-name-input") as HTMLInputElement; const afterBox = wrapper.querySelector(".diff-box.after") as HTMLElement; // Set initial values this.#titleEl.value = this.#title; this.#targetEl.value = this.#targetShapeId; this.#replTypeEl.value = this.#replacementType; this.#modeEl.value = this.#approvalMode; this.#descEl.value = this.#description; afterBox.textContent = this.#replacementType; this.#updateDiff(); this.#renderVoters(); this.#updateStatusBadge(); // Wire events const onChange = () => this.dispatchEvent(new CustomEvent("content-change")); this.#titleEl.addEventListener("input", (e) => { e.stopPropagation(); this.#title = this.#titleEl.value; onChange(); }); this.#targetEl.addEventListener("input", (e) => { e.stopPropagation(); this.#targetShapeId = this.#targetEl.value; this.#updateDiff(); onChange(); }); this.#replTypeEl.addEventListener("input", (e) => { e.stopPropagation(); this.#replacementType = this.#replTypeEl.value; afterBox.textContent = `type: ${this.#replacementType}`; onChange(); }); this.#modeEl.addEventListener("change", (e) => { e.stopPropagation(); this.#approvalMode = this.#modeEl.value as ApprovalMode; this.#checkApproval(); onChange(); }); this.#descEl.addEventListener("input", (e) => { e.stopPropagation(); this.#description = this.#descEl.value; onChange(); }); // Vote buttons wrapper.querySelector(".vote-btn.approve")!.addEventListener("click", (e) => { e.stopPropagation(); this.#castVote(true); onChange(); }); wrapper.querySelector(".vote-btn.reject")!.addEventListener("click", (e) => { e.stopPropagation(); this.#castVote(false); onChange(); }); // Apply button this.#applyBtn.addEventListener("click", (e) => { e.stopPropagation(); this.#applyAmendment(); onChange(); }); wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); // Prevent drag on inputs for (const el of wrapper.querySelectorAll("input, textarea, select, button")) { el.addEventListener("pointerdown", (e) => e.stopPropagation()); } // Handle approval-in port this.addEventListener("port-value-changed", ((e: CustomEvent) => { const { name, value } = e.detail; if (name === "approval-in" && value && typeof value === "object") { const v = value as any; if (v.voter && v.approve !== undefined) { this.#votes.push({ voter: v.voter, approve: !!v.approve, timestamp: v.timestamp || Date.now(), }); this.#renderVoters(); this.#checkApproval(); onChange(); } } }) as EventListener); return root; } #updateDiff() { if (!this.#beforeBox) return; if (!this.#targetShapeId) { this.#beforeBox.textContent = "(no target selected)"; return; } const target = document.getElementById(this.#targetShapeId) as any; if (!target) { this.#beforeBox.textContent = `(shape "${this.#targetShapeId}" not found)`; return; } const data = target.toJSON?.() || {}; this.#beforeBox.textContent = `type: ${data.type || target.tagName}\ntitle: ${data.title || "—"}`; } #castVote(approve: boolean) { const voter = this.#voteNameEl?.value.trim() || "anonymous"; // Prevent duplicate votes from same voter const existing = this.#votes.findIndex(v => v.voter === voter); if (existing >= 0) { this.#votes[existing] = { voter, approve, timestamp: Date.now() }; } else { this.#votes.push({ voter, approve, timestamp: Date.now() }); } if (this.#voteNameEl) this.#voteNameEl.value = ""; this.#renderVoters(); this.#checkApproval(); } #renderVoters() { if (!this.#votersEl) return; this.#votersEl.innerHTML = this.#votes.map(v => { const badge = v.approve ? `YES` : `NO`; return `
${v.voter}${badge}
`; }).join(""); } #checkApproval() { if (this.#amendmentStatus === "applied") return; const approvals = this.#votes.filter(v => v.approve).length; const rejections = this.#votes.filter(v => !v.approve).length; const total = this.#votes.length; let approved = false; if (this.#approvalMode === "single") { approved = approvals >= 1; } else if (this.#approvalMode === "majority") { approved = total > 0 && approvals > total / 2; } else if (this.#approvalMode === "unanimous") { approved = total > 0 && rejections === 0; } if (approved) { this.#amendmentStatus = "approved"; } else if (total > 0 && this.#approvalMode === "unanimous" && rejections > 0) { this.#amendmentStatus = "rejected"; } else { this.#amendmentStatus = "pending"; } this.#updateStatusBadge(); this.#emitPorts(); } #updateStatusBadge() { if (!this.#statusEl || !this.#applyBtn) return; this.#statusEl.textContent = this.#amendmentStatus.toUpperCase(); this.#statusEl.className = `status-badge ${this.#amendmentStatus}`; this.#applyBtn.disabled = this.#amendmentStatus !== "approved"; } #applyAmendment() { if (this.#amendmentStatus !== "approved") return; if (!this.#targetShapeId) return; // Dispatch event for canvas to handle the shape replacement this.dispatchEvent(new CustomEvent("gov-amendment-apply", { bubbles: true, composed: true, detail: { targetShapeId: this.#targetShapeId, replacementType: this.#replacementType, replacementData: this.#replacementData, amendmentId: this.id, }, })); this.#amendmentStatus = "applied"; this.#updateStatusBadge(); this.#emitPorts(); } #emitPorts() { const data = { status: this.#amendmentStatus, targetShapeId: this.#targetShapeId, approved: this.#amendmentStatus === "approved" || this.#amendmentStatus === "applied", }; this.setPortValue("amendment-out", data); this.setPortValue("gate-out", { satisfied: this.#amendmentStatus === "approved" || this.#amendmentStatus === "applied", }); } override toJSON() { return { ...super.toJSON(), type: "folk-gov-amendment", title: this.#title, targetShapeId: this.#targetShapeId, replacementType: this.#replacementType, replacementData: this.#replacementData, approvalMode: this.#approvalMode, votes: this.#votes, amendmentStatus: this.#amendmentStatus, description: this.#description, }; } static override fromData(data: Record): FolkGovAmendment { const shape = FolkShape.fromData.call(this, data) as FolkGovAmendment; if (data.title !== undefined) shape.title = data.title; if (data.targetShapeId !== undefined) shape.targetShapeId = data.targetShapeId; if (data.replacementType !== undefined) shape.replacementType = data.replacementType; if (data.replacementData !== undefined) shape.replacementData = data.replacementData; if (data.approvalMode !== undefined) shape.approvalMode = data.approvalMode; if (data.votes !== undefined) shape.votes = data.votes; if (data.amendmentStatus !== undefined) shape.amendmentStatus = data.amendmentStatus; if (data.description !== undefined) shape.description = data.description; return shape; } override applyData(data: Record): void { super.applyData(data); if (data.title !== undefined && data.title !== this.#title) this.title = data.title; if (data.targetShapeId !== undefined && data.targetShapeId !== this.#targetShapeId) this.targetShapeId = data.targetShapeId; if (data.replacementType !== undefined && data.replacementType !== this.#replacementType) this.replacementType = data.replacementType; if (data.replacementData !== undefined) this.replacementData = data.replacementData; if (data.approvalMode !== undefined && data.approvalMode !== this.#approvalMode) this.approvalMode = data.approvalMode; if (data.votes !== undefined) this.votes = data.votes; if (data.amendmentStatus !== undefined && data.amendmentStatus !== this.#amendmentStatus) this.amendmentStatus = data.amendmentStatus; if (data.description !== undefined && data.description !== this.#description) this.description = data.description; } }