607 lines
17 KiB
TypeScript
607 lines
17 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
}
|