import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; const USER_ID_KEY = "folk-choice-userid"; const USER_NAME_KEY = "folk-choice-username"; const styles = css` :host { background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); min-width: 340px; min-height: 380px; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #d97706; color: white; border-radius: 8px 8px 0 0; font-size: 12px; font-weight: 600; cursor: move; } .header-title { display: flex; align-items: center; gap: 6px; } .header-actions { display: flex; gap: 4px; } .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; height: calc(100% - 36px); overflow: hidden; } .options-list { flex: 1; overflow-y: auto; padding: 8px 12px; } .option-row { display: flex; align-items: center; gap: 6px; padding: 8px; border-radius: 6px; margin-bottom: 6px; background: #fffbeb; border: 1px solid #fde68a; position: relative; overflow: hidden; } .conviction-bg { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 6px; opacity: 0.12; transition: width 0.4s ease; } .option-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; z-index: 1; } .option-label { flex: 1; font-size: 13px; color: #1e293b; z-index: 1; } .stake-controls { display: flex; align-items: center; gap: 4px; z-index: 1; } .stake-btn { width: 22px; height: 22px; border: 1px solid #fbbf24; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; color: #92400e; } .stake-btn:hover { background: #fef3c7; } .stake-btn:disabled { opacity: 0.3; cursor: default; } .stake-count { font-size: 12px; font-variant-numeric: tabular-nums; min-width: 14px; text-align: center; color: #92400e; font-weight: 600; z-index: 1; } .option-conviction { font-size: 11px; color: #d97706; font-weight: 600; font-variant-numeric: tabular-nums; min-width: 40px; text-align: right; z-index: 1; } .option-time { font-size: 10px; color: #94a3b8; min-width: 32px; text-align: right; z-index: 1; } .weight-bar { padding: 4px 12px; font-size: 11px; color: #64748b; border-top: 1px solid #e2e8f0; text-align: center; } .weight-bar .used { font-weight: 600; color: #d97706; } .voters-count { padding: 4px 12px; font-size: 11px; color: #94a3b8; text-align: center; } .conviction-chart { padding: 4px 8px 0; border-bottom: 1px solid #e2e8f0; } .conviction-chart svg { width: 100%; display: block; } .conviction-chart:empty { display: none; } .add-form { display: flex; gap: 6px; padding: 8px 12px; border-top: 1px solid #e2e8f0; } .add-input { flex: 1; border: 1px solid #e2e8f0; border-radius: 6px; padding: 6px 10px; font-size: 12px; outline: none; } .add-input:focus { border-color: #d97706; } .add-btn { background: #d97706; color: white; border: none; border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 12px; font-weight: 500; } .add-btn:hover { background: #b45309; } .username-prompt { padding: 16px; text-align: center; } .username-prompt p { font-size: 13px; color: #64748b; margin: 0 0 8px; } .username-input { border: 1px solid #e2e8f0; border-radius: 6px; padding: 8px 12px; font-size: 13px; outline: none; width: 100%; box-sizing: border-box; margin-bottom: 8px; } .username-btn { background: #d97706; color: white; border: none; border-radius: 6px; padding: 8px 16px; cursor: pointer; font-weight: 500; font-size: 13px; } .wrapper { position: relative; height: 100%; } .results-drawer { position: absolute; top: 0; left: 100%; width: 300px; height: 100%; background: white; border-radius: 0 8px 8px 0; box-shadow: 4px 0 12px rgba(0,0,0,0.08); overflow-y: auto; display: none; flex-direction: column; font-size: 12px; z-index: 10; } .drawer-open .results-drawer { display: flex; } .drawer-section { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; } .drawer-heading { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px; } .stat-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 11px; } .stat-label { color: #64748b; } .stat-value { font-weight: 600; color: #1e293b; font-variant-numeric: tabular-nums; } .drawer-bar-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } .drawer-bar-label { font-size: 11px; min-width: 60px; color: #1e293b; } .drawer-bar-bg { flex: 1; height: 12px; background: #f1f5f9; border-radius: 3px; overflow: hidden; } .drawer-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; } .drawer-bar-val { font-size: 10px; font-weight: 600; min-width: 24px; text-align: right; font-variant-numeric: tabular-nums; } .participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; } .participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .drawer-toggle.active { background: rgba(255,255,255,0.3); } .settings-toggle.active { background: rgba(255,255,255,0.3); } .settings-panel { display: none; flex-direction: column; gap: 12px; padding: 12px; overflow-y: auto; height: calc(100% - 36px); } .settings-open .settings-panel { display: flex; } .settings-open .body { display: none !important; } .settings-open .results-drawer { display: none !important; } .settings-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px; } .settings-input { width: 100%; box-sizing: border-box; border: 1px solid #e2e8f0; border-radius: 6px; padding: 6px 10px; font-size: 12px; outline: none; } .settings-input:focus { border-color: #d97706; } .settings-item { display: flex; align-items: center; gap: 6px; padding: 4px 0; } .settings-item input[type="text"] { flex: 1; border: 1px solid #e2e8f0; border-radius: 4px; padding: 4px 6px; font-size: 11px; outline: none; min-width: 0; } .settings-item input[type="color"] { width: 24px; height: 24px; border: none; border-radius: 4px; padding: 0; cursor: pointer; background: none; } .settings-item .remove-btn { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 14px; padding: 2px 4px; border-radius: 4px; flex-shrink: 0; } .settings-item .remove-btn:hover { color: #ef4444; background: #fef2f2; } .settings-danger { background: none; border: 1px solid #fca5a5; color: #ef4444; border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 11px; width: 100%; margin-top: 4px; } .settings-danger:hover { background: #fef2f2; } .settings-done { background: #d97706; color: white; border: none; border-radius: 6px; padding: 8px 16px; cursor: pointer; font-size: 12px; font-weight: 500; width: 100%; margin-top: auto; } .settings-done:hover { background: #b45309; } `; // -- Data types -- export interface ConvictionOption { id: string; label: string; color: string; } export interface ConvictionStake { userId: string; userName: string; optionId: string; weight: number; since: number; } // -- Pure aggregation functions -- export function convictionScore( stakes: ConvictionStake[], optionId: string, now: number, ): number { let total = 0; for (const s of stakes) { if (s.optionId !== optionId) continue; total += s.weight * Math.max(0, now - s.since) / 3600000; } return total; } export function convictionVelocity( stakes: ConvictionStake[], optionId: string, ): number { let total = 0; for (const s of stakes) { if (s.optionId !== optionId) continue; total += s.weight; } return total; } // -- Component -- declare global { interface HTMLElementTagNameMap { "folk-choice-conviction": FolkChoiceConviction; } } const DEFAULT_COLORS = ["#f59e0b", "#3b82f6", "#22c55e", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"]; export class FolkChoiceConviction extends FolkShape { static override tagName = "folk-choice-conviction"; 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 Ranking"; #options: ConvictionOption[] = []; #stakes: ConvictionStake[] = []; #userId = ""; #userName = ""; #drawerOpen = false; #settingsOpen = false; #tickInterval: ReturnType | null = null; // DOM refs #wrapperEl: HTMLElement | null = null; #bodyEl: HTMLElement | null = null; #optionsEl: HTMLElement | null = null; #weightEl: HTMLElement | null = null; #votersEl: HTMLElement | null = null; #drawerEl: HTMLElement | null = null; #chartEl: HTMLElement | null = null; #settingsEl: HTMLElement | null = null; get title() { return this.#title; } set title(v: string) { this.#title = v; this.requestUpdate("title"); } get options() { return this.#options; } set options(v: ConvictionOption[]) { this.#options = v; this.#render(); this.requestUpdate("options"); } get stakes() { return this.#stakes; } set stakes(v: ConvictionStake[]) { this.#stakes = v; this.#render(); this.requestUpdate("stakes"); } #ensureIdentity(): boolean { if (this.#userId && this.#userName) return true; this.#userId = localStorage.getItem(USER_ID_KEY) || ""; this.#userName = localStorage.getItem(USER_NAME_KEY) || localStorage.getItem("rspace-username") || ""; if (!this.#userId) { this.#userId = crypto.randomUUID().slice(0, 8); localStorage.setItem(USER_ID_KEY, this.#userId); } return !!this.#userName; } #setUserName(name: string) { this.#userName = name; localStorage.setItem(USER_NAME_KEY, name); localStorage.setItem("rspace-username", name); } #getMyStake(optionId: string): ConvictionStake | undefined { return this.#stakes.find((s) => s.userId === this.#userId && s.optionId === optionId); } #setStake(optionId: string, delta: number) { if (!this.#ensureIdentity()) return; const existing = this.#getMyStake(optionId); const currentWeight = existing ? existing.weight : 0; const newWeight = Math.max(0, Math.min(10, currentWeight + delta)); if (newWeight === 0) { // Remove stake this.#stakes = this.#stakes.filter( (s) => !(s.userId === this.#userId && s.optionId === optionId), ); } else if (existing) { // Update — reset conviction timer on weight change existing.weight = newWeight; existing.since = Date.now(); } else { // New stake this.#stakes.push({ userId: this.#userId, userName: this.#userName, optionId, weight: newWeight, since: Date.now(), }); } this.#render(); this.dispatchEvent(new CustomEvent("content-change")); } override createRenderRoot() { const root = super.createRenderRoot(); this.#ensureIdentity(); const wrapper = document.createElement("div"); wrapper.className = "wrapper"; wrapper.innerHTML = html`
Conviction
`; const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) containerDiv.replaceWith(wrapper); this.#wrapperEl = wrapper; this.#bodyEl = wrapper.querySelector(".body") as HTMLElement; this.#optionsEl = wrapper.querySelector(".options-list") as HTMLElement; this.#weightEl = wrapper.querySelector(".weight-bar") as HTMLElement; this.#votersEl = wrapper.querySelector(".voters-count") as HTMLElement; this.#drawerEl = wrapper.querySelector(".results-drawer") as HTMLElement; this.#chartEl = wrapper.querySelector(".conviction-chart") as HTMLElement; const titleEl = wrapper.querySelector(".title-text") as HTMLElement; const drawerToggle = wrapper.querySelector(".drawer-toggle") as HTMLButtonElement; drawerToggle.addEventListener("click", (e) => { e.stopPropagation(); this.#drawerOpen = !this.#drawerOpen; this.#wrapperEl!.classList.toggle("drawer-open", this.#drawerOpen); drawerToggle.classList.toggle("active", this.#drawerOpen); if (this.#drawerOpen) this.#renderDrawer(); }); this.#settingsEl = wrapper.querySelector(".settings-panel") as HTMLElement; const settingsToggle = wrapper.querySelector(".settings-toggle") as HTMLButtonElement; settingsToggle.addEventListener("click", (e) => { e.stopPropagation(); this.#settingsOpen = !this.#settingsOpen; this.#wrapperEl!.classList.toggle("settings-open", this.#settingsOpen); settingsToggle.classList.toggle("active", this.#settingsOpen); if (this.#settingsOpen) this.#renderSettings(); else this.#render(); }); const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement; const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement; const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement; const addInput = wrapper.querySelector(".add-input") as HTMLInputElement; const addBtn = wrapper.querySelector(".add-btn") as HTMLButtonElement; if (!this.#userName) { this.#bodyEl.style.display = "none"; usernamePrompt.style.display = "block"; } const submitName = () => { const name = usernameInput.value.trim(); if (name) { this.#setUserName(name); this.#bodyEl!.style.display = "flex"; usernamePrompt.style.display = "none"; this.#render(); } }; usernameBtn.addEventListener("click", (e) => { e.stopPropagation(); submitName(); }); usernameInput.addEventListener("click", (e) => e.stopPropagation()); usernameInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") submitName(); }); // Add option const addOption = () => { const label = addInput.value.trim(); if (!label) return; this.#options.push({ id: `opt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, label, color: DEFAULT_COLORS[this.#options.length % DEFAULT_COLORS.length], }); addInput.value = ""; this.#render(); this.dispatchEvent(new CustomEvent("content-change")); }; addBtn.addEventListener("click", (e) => { e.stopPropagation(); addOption(); }); addInput.addEventListener("click", (e) => e.stopPropagation()); addInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") addOption(); }); wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); if (this.#title) titleEl.textContent = this.#title; // Live conviction update this.#tickInterval = setInterval(() => this.#render(), 10000); this.#render(); return root; } disconnectedCallback() { super.disconnectedCallback(); if (this.#tickInterval) { clearInterval(this.#tickInterval); this.#tickInterval = null; } } #render() { this.#renderOptions(); this.#renderChart(); if (this.#drawerOpen) this.#renderDrawer(); } #renderOptions() { if (!this.#optionsEl) return; const now = Date.now(); const convictions = this.#options.map((opt) => ({ id: opt.id, score: convictionScore(this.#stakes, opt.id, now), })); const maxConv = Math.max(1, ...convictions.map((c) => c.score)); const uniqueParticipants = new Set(this.#stakes.map((s) => s.userId)).size; // Sort options by conviction score (highest first) const sortedOptions = [...this.#options].sort((a, b) => { const scoreA = convictions.find((c) => c.id === a.id)!.score; const scoreB = convictions.find((c) => c.id === b.id)!.score; return scoreB - scoreA; }); this.#optionsEl.innerHTML = sortedOptions .map((opt) => { const conv = convictions.find((c) => c.id === opt.id)!; const barWidth = (conv.score / maxConv) * 100; const myStake = this.#getMyStake(opt.id); const myWeight = myStake ? myStake.weight : 0; const timeHeld = myStake ? this.#formatDuration(now - myStake.since) : ""; return `
${this.#escapeHtml(opt.label)}
${myWeight}
${timeHeld} ${this.#formatConviction(conv.score)}
`; }) .join(""); // Wire buttons this.#optionsEl.querySelectorAll(".stake-plus").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); this.#setStake((btn as HTMLElement).dataset.opt!, 1); }); }); this.#optionsEl.querySelectorAll(".stake-minus").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); this.#setStake((btn as HTMLElement).dataset.opt!, -1); }); }); // Weight bar if (this.#weightEl) { const totalWeight = this.#stakes .filter((s) => s.userId === this.#userId) .reduce((sum, s) => sum + s.weight, 0); this.#weightEl.innerHTML = `Your weight: ${totalWeight} across ${this.#stakes.filter((s) => s.userId === this.#userId).length} option${this.#stakes.filter((s) => s.userId === this.#userId).length !== 1 ? "s" : ""}`; } // Voters count if (this.#votersEl) { this.#votersEl.textContent = uniqueParticipants === 0 ? "No stakes yet" : `${uniqueParticipants} participant${uniqueParticipants !== 1 ? "s" : ""}`; } } #renderChart() { if (!this.#chartEl) return; if (this.#options.length === 0 || this.#stakes.length === 0) { this.#chartEl.innerHTML = ""; return; } const now = Date.now(); const W = 280; const H = 100; const PAD = { top: 10, right: 10, bottom: 18, left: 34 }; const plotW = W - PAD.left - PAD.right; const plotH = H - PAD.top - PAD.bottom; // Collect all inflection time points from stakes const timeSet = new Set(); for (const s of this.#stakes) timeSet.add(s.since); timeSet.add(now); const sortedTimes = [...timeSet].sort((a, b) => a - b); const earliest = sortedTimes[0]; const timeRange = Math.max(now - earliest, 60000); // at least 1 minute // Compute conviction curve for each option const curves: { id: string; color: string; points: { t: number; v: number }[] }[] = []; let maxV = 0; for (const opt of this.#options) { const optStakes = this.#stakes.filter((s) => s.optionId === opt.id); if (optStakes.length === 0) continue; const points: { t: number; v: number }[] = []; const optEarliest = Math.min(...optStakes.map((s) => s.since)); // Start at zero if (optEarliest > earliest) points.push({ t: earliest, v: 0 }); points.push({ t: optEarliest, v: 0 }); // Compute conviction at each time point after this option's first stake for (const t of sortedTimes) { if (t <= optEarliest) continue; let score = 0; for (const s of optStakes) { if (s.since <= t) score += s.weight * (t - s.since) / 3600000; } points.push({ t, v: score }); maxV = Math.max(maxV, score); } curves.push({ id: opt.id, color: opt.color, points }); } 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 = ``; // Grid svg += ``; svg += ``; if (maxV > 2) { const mid = maxV / 2; svg += ``; } // Area fill + line + end dot for each option for (const curve of curves) { if (curve.points.length < 2) continue; // Area const areaD = `M${x(curve.points[0].t)},${y(0)} ` + curve.points.map((p) => `L${x(p.t)},${y(p.v)}`).join(" ") + ` L${x(curve.points[curve.points.length - 1].t)},${y(0)} Z`; svg += ``; // Line const lineD = curve.points.map((p, i) => `${i === 0 ? "M" : "L"}${x(p.t)},${y(p.v)}`).join(" "); svg += ``; // End dot const last = curve.points[curve.points.length - 1]; svg += ``; } // Y axis labels svg += `${this.#formatConviction(maxV)}`; svg += `0`; // X axis labels const fmtRange = (ms: number) => { if (ms < 60000) return "<1m ago"; if (ms < 3600000) return `${Math.floor(ms / 60000)}m ago`; if (ms < 86400000) return `${Math.floor(ms / 3600000)}h ago`; return `${Math.floor(ms / 86400000)}d ago`; }; svg += `${fmtRange(timeRange)}`; svg += `now`; svg += ""; this.#chartEl.innerHTML = svg; } #renderDrawer() { if (!this.#drawerEl) return; const now = Date.now(); const optMap = new Map(this.#options.map((o) => [o.id, o])); // Group Results: conviction leaderboard const convictions = this.#options.map((opt) => ({ id: opt.id, label: opt.label, color: opt.color, score: convictionScore(this.#stakes, opt.id, now), velocity: convictionVelocity(this.#stakes, opt.id), rawWeight: this.#stakes.filter((s) => s.optionId === opt.id).reduce((sum, s) => sum + s.weight, 0), })); convictions.sort((a, b) => b.score - a.score); const maxConv = Math.max(1, ...convictions.map((c) => c.score)); let resultsHtml = '
Conviction Leaderboard
'; for (const c of convictions) { const pct = (c.score / maxConv) * 100; resultsHtml += `
${this.#escapeHtml(c.label)}
${this.#formatConviction(c.score)}
`; } resultsHtml += "
"; // Statistics const uniqueParticipants = new Set(this.#stakes.map((s) => s.userId)).size; const totalConv = convictions.reduce((sum, c) => sum + c.score, 0); const totalVelocity = convictions.reduce((sum, c) => sum + c.velocity, 0); let statsHtml = '
Statistics
'; statsHtml += `
Participants${uniqueParticipants}
`; statsHtml += `
Total conviction${this.#formatConviction(totalConv)}
`; statsHtml += `
Velocity (wt/hr)${totalVelocity.toFixed(1)}
`; // Raw weight vs conviction rank comparison const byRawWeight = [...convictions].sort((a, b) => b.rawWeight - a.rawWeight); const byConviction = convictions; // already sorted let rankDiff = false; for (let i = 0; i < convictions.length; i++) { if (byRawWeight[i]?.id !== byConviction[i]?.id) { rankDiff = true; break; } } if (rankDiff && convictions.length >= 2) { statsHtml += '
Raw Weight vs Conviction
'; for (const c of convictions) { const rawRank = byRawWeight.findIndex((r) => r.id === c.id) + 1; const convRank = byConviction.findIndex((r) => r.id === c.id) + 1; const diff = rawRank - convRank; const arrow = diff > 0 ? `↑${diff}` : diff < 0 ? `↓${Math.abs(diff)}` : "="; statsHtml += `
${this.#escapeHtml(c.label)}#${convRank} (${arrow})
`; } statsHtml += "
"; } statsHtml += "
"; // Participants const userStakes = new Map(); for (const s of this.#stakes) { const u = userStakes.get(s.userId) || { name: s.userName, totalWeight: 0, totalConv: 0, optCount: 0 }; u.name = s.userName; u.totalWeight += s.weight; u.totalConv += s.weight * Math.max(0, now - s.since) / 3600000; u.optCount++; userStakes.set(s.userId, u); } let participantsHtml = '
Participants
'; for (const [, u] of userStakes) { participantsHtml += `
${this.#escapeHtml(u.name)} wt:${u.totalWeight} conv:${this.#formatConviction(u.totalConv)}
`; } participantsHtml += "
"; this.#drawerEl.innerHTML = resultsHtml + statsHtml + participantsHtml; } #formatConviction(score: number): string { if (score < 1) return score.toFixed(2); if (score < 100) return score.toFixed(1); return Math.round(score).toString(); } #formatDuration(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`; } #timeAgo(ts: number): string { const diff = Date.now() - ts; if (diff < 60000) return "just now"; if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; return `${Math.floor(diff / 86400000)}d ago`; } #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } #renderSettings() { if (!this.#settingsEl) return; const esc = (s: string) => this.#escapeHtml(s); let h = '
Title
'; h += `
`; h += '
Options
'; for (let i = 0; i < this.#options.length; i++) { const opt = this.#options[i]; h += `
`; h += ``; h += ``; h += `
`; } h += '
'; const uniqueStakers = new Set(this.#stakes.map((s) => s.userId)).size; h += '
Danger Zone
'; h += `
`; h += ''; this.#settingsEl.innerHTML = h; const stop = (e: Event) => e.stopPropagation(); const titleInput = this.#settingsEl.querySelector(".settings-title") as HTMLInputElement; titleInput.addEventListener("click", stop); titleInput.addEventListener("input", () => { this.#title = titleInput.value; this.#wrapperEl!.querySelector(".title-text")!.textContent = this.#title; this.dispatchEvent(new CustomEvent("content-change")); }); this.#settingsEl.querySelectorAll(".opt-label").forEach((el) => { const input = el as HTMLInputElement; input.addEventListener("click", stop); input.addEventListener("input", () => { const idx = parseInt(input.closest(".settings-item")!.getAttribute("data-idx")!); this.#options[idx].label = input.value; this.dispatchEvent(new CustomEvent("content-change")); }); }); this.#settingsEl.querySelectorAll(".opt-color").forEach((el) => { const input = el as HTMLInputElement; input.addEventListener("click", stop); input.addEventListener("input", () => { const idx = parseInt(input.closest(".settings-item")!.getAttribute("data-idx")!); this.#options[idx].color = input.value; this.dispatchEvent(new CustomEvent("content-change")); }); }); this.#settingsEl.querySelectorAll(".remove-btn").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); const idx = parseInt((btn as HTMLElement).closest(".settings-item")!.getAttribute("data-idx")!); const removedId = this.#options[idx].id; this.#options.splice(idx, 1); this.#stakes = this.#stakes.filter((s) => s.optionId !== removedId); this.#renderSettings(); this.dispatchEvent(new CustomEvent("content-change")); }); }); this.#settingsEl.querySelector(".clear-data-btn")!.addEventListener("click", (e) => { e.stopPropagation(); this.#stakes = []; this.#renderSettings(); this.dispatchEvent(new CustomEvent("content-change")); }); this.#settingsEl.querySelector(".settings-done")!.addEventListener("click", (e) => { e.stopPropagation(); this.#settingsOpen = false; this.#wrapperEl!.classList.remove("settings-open"); this.#wrapperEl!.querySelector(".settings-toggle")!.classList.remove("active"); this.#render(); }); } override toJSON() { return { ...super.toJSON(), type: "folk-choice-conviction", title: this.#title, options: this.#options, stakes: this.#stakes, }; } }