diff --git a/modules/rvote/components/vote-demo.ts b/modules/rvote/components/vote-demo.ts index fdfb804..c38e0da 100644 --- a/modules/rvote/components/vote-demo.ts +++ b/modules/rvote/components/vote-demo.ts @@ -177,3 +177,133 @@ resetBtn.addEventListener("click", async () => { // ── Connect ── sync.connect(); + +// ═══════════════════════════════════════════════════════════ +// Conviction Voting Simulator (local-only, no sync) +// ═══════════════════════════════════════════════════════════ + +interface SimProposal { + id: string; + title: string; + description: string; + baseScore: number; + myVotes: number; // how many times user has upvoted (0..5) +} + +const MAX_CREDITS = 50; +let simCredits = MAX_CREDITS; +let simProposals: SimProposal[] = getInitialProposals(); + +function getInitialProposals(): SimProposal[] { + return [ + { id: "s1", title: "Add dark mode across all r* modules", description: "CSS custom properties for consistent theming", baseScore: 45, myVotes: 0 }, + { id: "s2", title: "Real-time collaboration in rNotes", description: "Automerge CRDT simultaneous editing", baseScore: 72, myVotes: 0 }, + { id: "s3", title: "Cosmolocal print-on-demand for merch", description: "Route orders to closest provider", baseScore: 38, myVotes: 0 }, + { id: "s4", title: "EncryptID passkeys for all auth", description: "One WebAuthn passkey, all apps", baseScore: 61, myVotes: 0 }, + { id: "s5", title: "Federated moderation tools", description: "Community-driven content policies", baseScore: 29, myVotes: 0 }, + ]; +} + +function quadraticCost(votes: number): number { + return votes * votes; +} + +function totalSpent(): number { + return simProposals.reduce((sum, p) => sum + quadraticCost(p.myVotes), 0); +} + +function effectiveScore(p: SimProposal): number { + return p.baseScore + p.myVotes; +} + +function costOfNextVote(p: SimProposal): number { + return quadraticCost(p.myVotes + 1) - quadraticCost(p.myVotes); +} + +function canAffordNextVote(p: SimProposal): boolean { + return costOfNextVote(p) <= simCredits && p.myVotes < 5; +} + +function renderSimulator(): void { + const simContainer = document.getElementById("rd-sim-proposals"); + const creditsEl = document.getElementById("rd-credits"); + if (!simContainer || !creditsEl) return; + + simCredits = MAX_CREDITS - totalSpent(); + creditsEl.textContent = String(simCredits); + creditsEl.style.color = simCredits < 10 ? "#ef4444" : simCredits < 20 ? "#f97316" : "#22c55e"; + + // Sort by effective score descending + const sorted = [...simProposals].sort((a, b) => effectiveScore(b) - effectiveScore(a)); + + simContainer.innerHTML = sorted.map((p, rank) => { + const score = effectiveScore(p); + const mySpent = quadraticCost(p.myVotes); + const nextCost = costOfNextVote(p); + const canVote = canAffordNextVote(p); + const canUnvote = p.myVotes > 0; + const promotionPct = Math.min(100, (score / 100) * 100); + + return `
+
+ +
+ + ${score} + +
+ +
+
+ #${rank + 1} + ${esc(p.title)} + ${p.myVotes > 0 ? `You: ${p.myVotes} vote${p.myVotes > 1 ? 's' : ''} (${mySpent} credit${mySpent > 1 ? 's' : ''})` : ''} +
+

${esc(p.description)}

+ +
+
+
+
+ ${score}/100 + ${score >= 100 ? 'PROMOTED' : ''} +
+ ${canVote ? `
Next vote costs ${nextCost} credit${nextCost > 1 ? 's' : ''}
` : ''} +
+
+
`; + }).join(""); +} + +// Event delegation for simulator votes +document.getElementById("rd-sim-proposals")?.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest("button.rd-sim-vote"); + if (!btn || btn.disabled) return; + + const id = btn.dataset.simId!; + const dir = btn.dataset.simDir!; + const proposal = simProposals.find((p) => p.id === id); + if (!proposal) return; + + if (dir === "up" && canAffordNextVote(proposal)) { + proposal.myVotes++; + } else if (dir === "down" && proposal.myVotes > 0) { + proposal.myVotes--; + } + + renderSimulator(); +}); + +// Reset simulator +document.getElementById("rd-sim-reset")?.addEventListener("click", () => { + simProposals = getInitialProposals(); + simCredits = MAX_CREDITS; + renderSimulator(); +}); + +// Initial render +renderSimulator(); diff --git a/modules/rvote/components/vote.css b/modules/rvote/components/vote.css index a13943e..c84f94d 100644 --- a/modules/rvote/components/vote.css +++ b/modules/rvote/components/vote.css @@ -192,10 +192,16 @@ folk-vote-dashboard { } .rd-footer a:hover { color: #818cf8; } +/* ── Simulator ── */ +.rd-sim-card:hover { border-color: rgba(129, 140, 248, 0.4) !important; } + /* ── Responsive ── */ @media (max-width: 600px) { .rd-page { padding: 1rem 0.75rem 3rem; } .rd-hero h1 { font-size: 1.5rem; } .rd-card-header { padding: 0.75rem 1rem 0.4rem; } .rd-card-body { padding: 0.4rem 1rem 1rem; } + .rd-page > div > [style*="grid-template-columns:repeat(3"] { + grid-template-columns: 1fr !important; + } } diff --git a/modules/rvote/mod.ts b/modules/rvote/mod.ts index 6622288..6ce422f 100644 --- a/modules/rvote/mod.ts +++ b/modules/rvote/mod.ts @@ -524,10 +524,86 @@ routes.get("/demo", (c) => {

These polls are synced in real-time across the entire r* ecosystem via rSpace. Vote on options and watch tallies update live for everyone.

-
- Connecting - - ← About rVote + +
+

How rVote Works

+
+
+
+
+ +
+

Quadratic

+
+

+ Voting more costs exponentially. 1 vote = 1 credit, 2 = 4, 3 = 9. + No single voice can dominate. +

+
+
+
+
+ +
+

Reddit-style

+
+

+ Upvote or downvote proposals. Scores aggregate from all votes. + Best ideas rise to the top. +

+
+
+
+
+ +
+

Vote Decay

+
+

+ Votes fade after 30–60 days. Old support expires naturally. + Rankings stay fresh. +

+
+
+
+ + +
+
+
+ Try It +

Conviction Voting Simulator

+
+
+ Your credits: + 50 + / 50 +
+
+

+ Upvote proposals to rank them. Each additional vote costs quadratically more credits (1, 4, 9, 16...). Proposals re-rank by score in real-time. +

+
+
+ +
+
+ + +
+
+
+ Live +

Community Polls

+
+
+ Connecting + +
+
+

+ These polls sync across all r* apps in real-time via WebSocket. +