/** * folk-gov-conviction — Conviction Accumulator * * Dual-mode GovMod: Gate mode accumulates conviction over time and emits * satisfied when score >= threshold. Tuner mode continuously emits the * current conviction score as a dynamic value for downstream wiring. */ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; import type { PortDescriptor } from "./data-types"; import { convictionScore, convictionVelocity } from "./folk-choice-conviction"; import type { ConvictionStake } from "./folk-choice-conviction"; const HEADER_COLOR = "#d97706"; type ConvictionMode = "gate" | "tuner"; const styles = css` :host { background: var(--rs-bg-surface, #1e293b); border-radius: 10px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); min-width: 240px; min-height: 160px; overflow: hidden; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: ${HEADER_COLOR}; color: white; font-size: 12px; font-weight: 600; cursor: move; border-radius: 10px 10px 0 0; } .header-title { display: flex; align-items: center; gap: 6px; } .header-actions button { background: transparent; border: none; color: white; cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 14px; } .header-actions button:hover { background: rgba(255, 255, 255, 0.2); } .body { display: flex; flex-direction: column; padding: 12px; gap: 8px; } .title-input { background: transparent; border: none; color: var(--rs-text-primary, #e2e8f0); font-size: 13px; font-weight: 600; width: 100%; outline: none; } .title-input::placeholder { color: var(--rs-text-muted, #64748b); } .config-row { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--rs-text-muted, #94a3b8); } .mode-select, .threshold-input { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; color: var(--rs-text-primary, #e2e8f0); font-size: 11px; padding: 2px 6px; outline: none; } .threshold-input { width: 60px; text-align: right; } .progress-wrap { position: relative; height: 20px; background: rgba(255, 255, 255, 0.08); border-radius: 10px; overflow: hidden; } .progress-bar { height: 100%; border-radius: 10px; transition: width 0.3s, background 0.3s; background: ${HEADER_COLOR}; } .progress-bar.complete { background: #22c55e; box-shadow: 0 0 8px rgba(34, 197, 94, 0.4); } .progress-label { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 600; color: white; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); } .score-display { font-size: 20px; font-weight: 700; color: ${HEADER_COLOR}; text-align: center; font-variant-numeric: tabular-nums; } .velocity-label { font-size: 10px; color: var(--rs-text-muted, #94a3b8); text-align: center; } .chart-area svg { width: 100%; display: block; } .stakes-list { max-height: 80px; overflow-y: auto; font-size: 10px; color: var(--rs-text-muted, #94a3b8); } .stake-item { display: flex; justify-content: space-between; padding: 2px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.05); } .status-label { font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; text-align: center; } .status-label.satisfied { color: #22c55e; } .status-label.waiting { color: #f59e0b; } .status-label.tuner { color: ${HEADER_COLOR}; } `; declare global { interface HTMLElementTagNameMap { "folk-gov-conviction": FolkGovConviction; } } export class FolkGovConviction extends FolkShape { static override tagName = "folk-gov-conviction"; static override portDescriptors: PortDescriptor[] = [ { name: "stake-in", type: "json", direction: "input" }, { name: "threshold-in", type: "number", direction: "input" }, { name: "conviction-out", type: "json", direction: "output" }, { name: "gate-out", type: "json", direction: "output" }, ]; static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n"); const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n"); sheet.replaceSync(`${parentRules}\n${childRules}`); this.styles = sheet; } #title = "Conviction Gate"; #convictionMode: ConvictionMode = "gate"; #threshold = 10; #stakes: ConvictionStake[] = []; #tickInterval: ReturnType | null = null; // DOM refs #titleEl!: HTMLInputElement; #modeEl!: HTMLSelectElement; #thresholdEl!: HTMLInputElement; #thresholdRow!: HTMLElement; #progressWrap!: HTMLElement; #progressBar!: HTMLElement; #progressLabel!: HTMLElement; #scoreDisplay!: HTMLElement; #velocityLabel!: HTMLElement; #chartEl!: HTMLElement; #stakesList!: HTMLElement; #statusEl!: HTMLElement; get title() { return this.#title; } set title(v: string) { this.#title = v; if (this.#titleEl) this.#titleEl.value = v; } get convictionMode() { return this.#convictionMode; } set convictionMode(v: ConvictionMode) { this.#convictionMode = v; if (this.#modeEl) this.#modeEl.value = v; this.#updateLayout(); this.#updateVisuals(); this.#emitPorts(); } get threshold() { return this.#threshold; } set threshold(v: number) { this.#threshold = v; if (this.#thresholdEl) this.#thresholdEl.value = String(v); this.#updateVisuals(); this.#emitPorts(); } get stakes(): ConvictionStake[] { return [...this.#stakes]; } set stakes(v: ConvictionStake[]) { this.#stakes = v; this.#updateVisuals(); this.#emitPorts(); } #getTotalScore(): number { // Aggregate conviction across all stakes (single "option" = this gate) const now = Date.now(); let total = 0; for (const s of this.#stakes) { total += s.weight * Math.max(0, now - s.since) / 3600000; } return total; } #getTotalVelocity(): number { return this.#stakes.reduce((sum, s) => sum + s.weight, 0); } override createRenderRoot() { const root = super.createRenderRoot(); this.initPorts(); const wrapper = document.createElement("div"); wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; wrapper.innerHTML = html`
⏳ Conviction
Mode: Threshold:
0 / 10
WAITING
`; const slot = root.querySelector("slot"); const container = slot?.parentElement as HTMLElement; if (container) container.replaceWith(wrapper); // Cache refs this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; this.#modeEl = wrapper.querySelector(".mode-select") as HTMLSelectElement; this.#thresholdEl = wrapper.querySelector(".threshold-input") as HTMLInputElement; this.#thresholdRow = wrapper.querySelector(".threshold-row") as HTMLElement; this.#progressWrap = wrapper.querySelector(".progress-wrap") as HTMLElement; this.#progressBar = wrapper.querySelector(".progress-bar") as HTMLElement; this.#progressLabel = wrapper.querySelector(".progress-label") as HTMLElement; this.#scoreDisplay = wrapper.querySelector(".score-display") as HTMLElement; this.#velocityLabel = wrapper.querySelector(".velocity-label") as HTMLElement; this.#chartEl = wrapper.querySelector(".chart-area") as HTMLElement; this.#stakesList = wrapper.querySelector(".stakes-list") as HTMLElement; this.#statusEl = wrapper.querySelector(".status-label") as HTMLElement; // Set initial values this.#titleEl.value = this.#title; this.#modeEl.value = this.#convictionMode; this.#thresholdEl.value = String(this.#threshold); this.#updateLayout(); this.#updateVisuals(); // Wire events this.#titleEl.addEventListener("input", (e) => { e.stopPropagation(); this.#title = this.#titleEl.value; this.dispatchEvent(new CustomEvent("content-change")); }); this.#modeEl.addEventListener("change", (e) => { e.stopPropagation(); this.#convictionMode = this.#modeEl.value as ConvictionMode; this.#updateLayout(); this.#updateVisuals(); this.#emitPorts(); this.dispatchEvent(new CustomEvent("content-change")); }); this.#thresholdEl.addEventListener("input", (e) => { e.stopPropagation(); this.#threshold = parseFloat(this.#thresholdEl.value) || 0; this.#updateVisuals(); this.#emitPorts(); this.dispatchEvent(new CustomEvent("content-change")); }); wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); // Prevent drag on inputs for (const el of wrapper.querySelectorAll("input, select, button")) { el.addEventListener("pointerdown", (e) => e.stopPropagation()); } // Handle input ports this.addEventListener("port-value-changed", ((e: CustomEvent) => { const { name, value } = e.detail; if (name === "stake-in" && value && typeof value === "object") { const v = value as any; const stake: ConvictionStake = { userId: v.userId || v.who || crypto.randomUUID().slice(0, 8), userName: v.userName || v.who || "anonymous", optionId: "gate", weight: v.weight || v.amount || 1, since: v.since || Date.now(), }; // Update existing or add const idx = this.#stakes.findIndex(s => s.userId === stake.userId); if (idx >= 0) { this.#stakes[idx] = stake; } else { this.#stakes.push(stake); } this.#updateVisuals(); this.#emitPorts(); this.dispatchEvent(new CustomEvent("content-change")); } if (name === "threshold-in" && typeof value === "number") { this.#threshold = value; if (this.#thresholdEl) this.#thresholdEl.value = String(value); this.#updateVisuals(); this.#emitPorts(); this.dispatchEvent(new CustomEvent("content-change")); } }) as EventListener); // Tick timer for live conviction updates this.#tickInterval = setInterval(() => { this.#updateVisuals(); this.#emitPorts(); }, 10000); return root; } override disconnectedCallback() { super.disconnectedCallback(); if (this.#tickInterval) { clearInterval(this.#tickInterval); this.#tickInterval = null; } } #updateLayout() { if (!this.#thresholdRow) return; const isGate = this.#convictionMode === "gate"; this.#thresholdRow.style.display = isGate ? "" : "none"; if (this.#progressWrap) this.#progressWrap.style.display = isGate ? "" : "none"; if (this.#scoreDisplay) this.#scoreDisplay.style.display = isGate ? "none" : ""; } #updateVisuals() { const score = this.#getTotalScore(); const velocity = this.#getTotalVelocity(); if (this.#convictionMode === "gate") { // Gate mode: progress bar const pct = this.#threshold > 0 ? Math.min(100, (score / this.#threshold) * 100) : 0; const satisfied = score >= this.#threshold; if (this.#progressBar) { this.#progressBar.style.width = `${pct}%`; this.#progressBar.classList.toggle("complete", satisfied); } if (this.#progressLabel) { this.#progressLabel.textContent = `${this.#fmtScore(score)} / ${this.#threshold}`; } if (this.#statusEl) { this.#statusEl.textContent = satisfied ? "SATISFIED" : "WAITING"; this.#statusEl.className = `status-label ${satisfied ? "satisfied" : "waiting"}`; } } else { // Tuner mode: score display if (this.#scoreDisplay) { this.#scoreDisplay.textContent = this.#fmtScore(score); } if (this.#statusEl) { this.#statusEl.textContent = "EMITTING"; this.#statusEl.className = "status-label tuner"; } } if (this.#velocityLabel) { this.#velocityLabel.textContent = `velocity: ${velocity.toFixed(1)} wt/hr`; } this.#renderChart(); this.#renderStakes(); } #renderChart() { if (!this.#chartEl || this.#stakes.length === 0) { if (this.#chartEl) this.#chartEl.innerHTML = ""; return; } const now = Date.now(); const W = 220; const H = 60; const PAD = { top: 6, right: 8, bottom: 12, left: 28 }; const plotW = W - PAD.left - PAD.right; const plotH = H - PAD.top - PAD.bottom; const earliest = Math.min(...this.#stakes.map(s => s.since)); const timeRange = Math.max(now - earliest, 60000); // Sample conviction curve at 20 points const SAMPLES = 20; const points: { t: number; v: number }[] = []; let maxV = 0; for (let i = 0; i <= SAMPLES; i++) { const t = earliest + (timeRange * i) / SAMPLES; let v = 0; for (const s of this.#stakes) { if (s.since <= t) v += s.weight * Math.max(0, t - s.since) / 3600000; } points.push({ t, v }); maxV = Math.max(maxV, v); } if (maxV === 0) maxV = 1; const x = (t: number) => PAD.left + ((t - earliest) / timeRange) * plotW; const y = (v: number) => PAD.top + (1 - v / maxV) * plotH; let svg = ``; // Threshold line in gate mode if (this.#convictionMode === "gate" && this.#threshold > 0 && this.#threshold <= maxV) { const ty = y(this.#threshold); svg += ``; } // Area const areaD = `M${x(points[0].t)},${y(0)} ` + points.map(p => `L${x(p.t)},${y(p.v)}`).join(" ") + ` L${x(points[points.length - 1].t)},${y(0)} Z`; svg += ``; // Line const lineD = points.map((p, i) => `${i === 0 ? "M" : "L"}${x(p.t)},${y(p.v)}`).join(" "); svg += ``; // End dot const last = points[points.length - 1]; svg += ``; // Y axis svg += `${this.#fmtScore(maxV)}`; svg += `0`; svg += ""; this.#chartEl.innerHTML = svg; } #renderStakes() { if (!this.#stakesList) return; const now = Date.now(); this.#stakesList.innerHTML = this.#stakes.map(s => { const dur = this.#fmtDuration(now - s.since); return `
${s.userName} (wt:${s.weight})${dur}
`; }).join(""); } #emitPorts() { const score = this.#getTotalScore(); const velocity = this.#getTotalVelocity(); const satisfied = this.#convictionMode === "gate" ? score >= this.#threshold : true; this.setPortValue("conviction-out", { score, velocity, stakeCount: this.#stakes.length, mode: this.#convictionMode, }); this.setPortValue("gate-out", { satisfied, score, threshold: this.#threshold, mode: this.#convictionMode, }); } #fmtScore(v: number): string { if (v < 1) return v.toFixed(2); if (v < 100) return v.toFixed(1); return Math.round(v).toString(); } #fmtDuration(ms: number): string { if (ms < 60000) return "<1m"; if (ms < 3600000) return `${Math.floor(ms / 60000)}m`; if (ms < 86400000) return `${Math.floor(ms / 3600000)}h`; return `${Math.floor(ms / 86400000)}d`; } override toJSON() { return { ...super.toJSON(), type: "folk-gov-conviction", title: this.#title, convictionMode: this.#convictionMode, threshold: this.#threshold, stakes: this.#stakes, }; } static override fromData(data: Record): FolkGovConviction { const shape = FolkShape.fromData.call(this, data) as FolkGovConviction; if (data.title !== undefined) shape.title = data.title; if (data.convictionMode !== undefined) shape.convictionMode = data.convictionMode; if (data.threshold !== undefined) shape.threshold = data.threshold; if (data.stakes !== undefined) shape.stakes = data.stakes; return shape; } override applyData(data: Record): void { super.applyData(data); if (data.title !== undefined && data.title !== this.#title) this.title = data.title; if (data.convictionMode !== undefined && data.convictionMode !== this.#convictionMode) this.convictionMode = data.convictionMode; if (data.threshold !== undefined && data.threshold !== this.#threshold) this.threshold = data.threshold; if (data.stakes !== undefined && JSON.stringify(data.stakes) !== JSON.stringify(this.#stakes)) this.stakes = data.stakes; } }