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: 320px; min-height: 300px; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #0d9488; 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; } .mode-tabs { display: flex; border-bottom: 1px solid #e2e8f0; } .mode-tab { flex: 1; padding: 6px 8px; text-align: center; font-size: 11px; font-weight: 500; cursor: pointer; border: none; background: transparent; color: #64748b; transition: all 0.15s; } .mode-tab.active { color: #0d9488; border-bottom: 2px solid #0d9488; background: #f0fdfa; } .options-list { flex: 1; overflow-y: auto; padding: 8px 12px; } .option-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: 6px; margin-bottom: 6px; cursor: pointer; transition: background 0.15s; position: relative; overflow: hidden; } .option-row:hover { background: #f8fafc; } .option-row.voted { background: #f0fdfa; } .bar-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; z-index: 1; color: #1e293b; } .option-count { font-size: 12px; color: #64748b; font-variant-numeric: tabular-nums; z-index: 1; min-width: 20px; text-align: right; } .option-pct { font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; z-index: 1; min-width: 32px; text-align: right; } .qv-controls { display: flex; align-items: center; gap: 4px; z-index: 1; } .qv-btn { width: 22px; height: 22px; border: 1px solid #cbd5e1; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; color: #475569; } .qv-btn:hover { background: #f1f5f9; } .qv-btn:disabled { opacity: 0.3; cursor: default; } .qv-count { font-size: 12px; font-variant-numeric: tabular-nums; min-width: 14px; text-align: center; color: #1e293b; } .budget-bar { padding: 4px 12px; font-size: 11px; color: #64748b; border-top: 1px solid #e2e8f0; text-align: center; } .budget-bar .used { font-weight: 600; color: #0d9488; } .voters-count { padding: 4px 12px; font-size: 11px; color: #94a3b8; text-align: center; } .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: #0d9488; } .add-btn { background: #0d9488; color: white; border: none; border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 12px; font-weight: 500; } .add-btn:hover { background: #0f766e; } .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: #0d9488; color: white; border: none; border-radius: 6px; padding: 8px 16px; cursor: pointer; font-weight: 500; font-size: 13px; } `; // -- Data types -- export interface VoteOption { id: string; label: string; color: string; } export interface UserVote { userId: string; userName: string; allocations: Record; timestamp: number; } export type VoteMode = "plurality" | "approval" | "quadratic"; // -- Pure aggregation functions -- export function tallyVotes( votes: UserVote[], options: VoteOption[], ): Map { const tally = new Map(); for (const opt of options) tally.set(opt.id, 0); for (const v of votes) { for (const [optId, count] of Object.entries(v.allocations)) { if (tally.has(optId)) { tally.set(optId, tally.get(optId)! + count); } } } return tally; } export function quadraticCost(allocations: Record): number { let total = 0; for (const k of Object.values(allocations)) { total += k * k; } return total; } // -- Component -- declare global { interface HTMLElementTagNameMap { "folk-choice-vote": FolkChoiceVote; } } const DEFAULT_COLORS = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"]; export class FolkChoiceVote extends FolkShape { static override tagName = "folk-choice-vote"; 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 = "Quick Poll"; #options: VoteOption[] = []; #mode: VoteMode = "plurality"; #budget = 100; #votes: UserVote[] = []; #userId = ""; #userName = ""; // DOM refs #bodyEl: HTMLElement | null = null; #optionsEl: HTMLElement | null = null; #budgetEl: HTMLElement | null = null; #votersEl: 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: VoteOption[]) { this.#options = v; this.#render(); this.requestUpdate("options"); } get mode() { return this.#mode; } set mode(v: VoteMode) { this.#mode = v; this.#render(); this.requestUpdate("mode"); } get budget() { return this.#budget; } set budget(v: number) { this.#budget = v; this.#render(); this.requestUpdate("budget"); } get votes() { return this.#votes; } set votes(v: UserVote[]) { this.#votes = v; this.#render(); this.requestUpdate("votes"); } #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); } #getUserVote(): UserVote | undefined { return this.#votes.find((v) => v.userId === this.#userId); } override createRenderRoot() { const root = super.createRenderRoot(); this.#ensureIdentity(); const wrapper = document.createElement("div"); wrapper.innerHTML = html`
Poll
`; const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) containerDiv.replaceWith(wrapper); this.#bodyEl = wrapper.querySelector(".body") as HTMLElement; this.#optionsEl = wrapper.querySelector(".options-list") as HTMLElement; this.#budgetEl = wrapper.querySelector(".budget-bar") as HTMLElement; this.#votersEl = wrapper.querySelector(".voters-count") as HTMLElement; const titleEl = wrapper.querySelector(".title-text") as HTMLElement; 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; // Show username prompt if needed 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(); }); // Mode tabs wrapper.querySelectorAll(".mode-tab").forEach((tab) => { tab.addEventListener("click", (e) => { e.stopPropagation(); const m = (tab as HTMLElement).dataset.mode as VoteMode; this.#mode = m; wrapper.querySelectorAll(".mode-tab").forEach((t) => t.classList.remove("active")); tab.classList.add("active"); this.#render(); this.dispatchEvent(new CustomEvent("content-change")); }); }); // 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(); }); // Close wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); // Title if (this.#title) titleEl.textContent = this.#title; this.#render(); return root; } #castVote(optionId: string, delta: number) { if (!this.#ensureIdentity()) return; let vote = this.#getUserVote(); if (!vote) { vote = { userId: this.#userId, userName: this.#userName, allocations: {}, timestamp: Date.now() }; this.#votes.push(vote); } const current = vote.allocations[optionId] || 0; if (this.#mode === "plurality") { // Toggle: clear all, then set this one (or clear if already voted) const wasVoted = current > 0; for (const key of Object.keys(vote.allocations)) vote.allocations[key] = 0; if (!wasVoted) vote.allocations[optionId] = 1; } else if (this.#mode === "approval") { // Toggle this option vote.allocations[optionId] = current > 0 ? 0 : 1; } else { // Quadratic: increment/decrement const next = Math.max(0, current + delta); const testAlloc = { ...vote.allocations, [optionId]: next }; if (quadraticCost(testAlloc) <= this.#budget) { vote.allocations[optionId] = next; } } vote.timestamp = Date.now(); this.#render(); this.dispatchEvent(new CustomEvent("content-change")); } #render() { if (!this.#optionsEl) return; const tally = tallyVotes(this.#votes, this.#options); const maxVotes = Math.max(1, ...tally.values()); const totalVotes = [...tally.values()].reduce((a, b) => a + b, 0); const myVote = this.#getUserVote(); const uniqueVoters = new Set(this.#votes.map((v) => v.userId)).size; this.#optionsEl.innerHTML = this.#options .map((opt) => { const count = tally.get(opt.id) || 0; const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0; const barWidth = (count / maxVotes) * 100; const myAlloc = myVote?.allocations[opt.id] || 0; const isVoted = myAlloc > 0; let controls = ""; if (this.#mode === "quadratic") { controls = `
${myAlloc}
`; } return `
${this.#escapeHtml(opt.label)} ${controls} ${count} ${pct.toFixed(0)}%
`; }) .join(""); // Budget display for quadratic if (this.#budgetEl) { if (this.#mode === "quadratic") { const used = myVote ? quadraticCost(myVote.allocations) : 0; this.#budgetEl.style.display = "block"; this.#budgetEl.innerHTML = `Credits: ${used} / ${this.#budget}`; } else { this.#budgetEl.style.display = "none"; } } // Voter count if (this.#votersEl) { this.#votersEl.textContent = uniqueVoters === 0 ? "No votes yet" : `${uniqueVoters} voter${uniqueVoters !== 1 ? "s" : ""}`; } // Wire click events if (this.#mode !== "quadratic") { this.#optionsEl.querySelectorAll(".option-row").forEach((row) => { row.addEventListener("click", (e) => { e.stopPropagation(); const optId = (row as HTMLElement).dataset.opt!; this.#castVote(optId, 1); }); }); } else { this.#optionsEl.querySelectorAll(".qv-plus").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); this.#castVote((btn as HTMLElement).dataset.opt!, 1); }); }); this.#optionsEl.querySelectorAll(".qv-minus").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); this.#castVote((btn as HTMLElement).dataset.opt!, -1); }); }); } } #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } override toJSON() { return { ...super.toJSON(), type: "folk-choice-vote", title: this.#title, options: this.#options, mode: this.#mode, budget: this.#budget, votes: this.#votes, }; } }