feat(rvote): add QPR explanations and conviction voting simulator to demo
Add ELI5 cards (quadratic, reddit-style ranking, vote decay) and an interactive conviction voting simulator with credit budget, quadratic costs, proposal ranking, and promotion threshold progress bars. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
34ece96927
commit
f9fc0ca6ec
|
|
@ -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 `<div class="rd-card rd-sim-card" data-sim-id="${p.id}" style="border:2px solid rgba(129,140,248,0.2);margin-bottom:0.75rem;transition:all 0.3s">
|
||||
<div style="display:flex;align-items:stretch">
|
||||
<!-- Vote column -->
|
||||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;padding:0.75rem 0.5rem;min-width:3.5rem;border-right:1px solid #1e293b;gap:0.15rem">
|
||||
<button class="rd-btn rd-btn--ghost rd-sim-vote" data-sim-dir="up" data-sim-id="${p.id}"
|
||||
style="width:1.75rem;height:1.75rem;padding:0;justify-content:center;font-size:1rem;${p.myVotes > 0 ? 'border-color:#f97316;color:#fb923c' : ''}"
|
||||
${!canVote ? 'disabled' : ''}>▲</button>
|
||||
<span style="font-weight:700;font-size:1.1rem;color:${p.myVotes > 0 ? '#fb923c' : '#e2e8f0'};font-family:monospace">${score}</span>
|
||||
<button class="rd-btn rd-btn--ghost rd-sim-vote" data-sim-dir="down" data-sim-id="${p.id}"
|
||||
style="width:1.75rem;height:1.75rem;padding:0;justify-content:center;font-size:1rem"
|
||||
${!canUnvote ? 'disabled' : ''}>▼</button>
|
||||
</div>
|
||||
<!-- Content column -->
|
||||
<div style="flex:1;padding:0.75rem 1rem;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.35rem;flex-wrap:wrap">
|
||||
<span style="font-size:0.65rem;color:#475569;font-weight:700;font-family:monospace">#${rank + 1}</span>
|
||||
<span style="font-size:0.9rem;font-weight:600;color:#e2e8f0">${esc(p.title)}</span>
|
||||
${p.myVotes > 0 ? `<span class="rd-badge rd-badge--orange" style="font-size:0.6rem">You: ${p.myVotes} vote${p.myVotes > 1 ? 's' : ''} (${mySpent} credit${mySpent > 1 ? 's' : ''})</span>` : ''}
|
||||
</div>
|
||||
<p style="font-size:0.78rem;color:#64748b;margin:0 0 0.5rem">${esc(p.description)}</p>
|
||||
<!-- Promotion progress bar -->
|
||||
<div style="display:flex;align-items:center;gap:0.5rem">
|
||||
<div style="flex:1;height:4px;border-radius:2px;background:#1e293b;overflow:hidden">
|
||||
<div style="height:100%;border-radius:2px;background:${score >= 100 ? '#22c55e' : 'linear-gradient(90deg,#818cf8,#c084fc)'};width:${promotionPct}%;transition:width 0.3s"></div>
|
||||
</div>
|
||||
<span style="font-size:0.65rem;color:#475569;white-space:nowrap;font-family:monospace">${score}/100</span>
|
||||
${score >= 100 ? '<span class="rd-badge rd-badge--green" style="font-size:0.55rem">PROMOTED</span>' : ''}
|
||||
</div>
|
||||
${canVote ? `<div style="font-size:0.65rem;color:#475569;margin-top:0.35rem">Next vote costs <strong style="color:#c084fc">${nextCost}</strong> credit${nextCost > 1 ? 's' : ''}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// Event delegation for simulator votes
|
||||
document.getElementById("rd-sim-proposals")?.addEventListener("click", (e) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -524,10 +524,86 @@ routes.get("/demo", (c) => {
|
|||
<p>These polls are synced in real-time across the entire r* ecosystem via rSpace. Vote on options and watch tallies update live for everyone.</p>
|
||||
</div>
|
||||
|
||||
<div class="rd-toolbar">
|
||||
<span id="rd-conn-badge" class="rd-status rd-status--disconnected">Connecting</span>
|
||||
<button id="rd-reset-btn" class="rd-btn" disabled>Reset Demo</button>
|
||||
<a href="/rvote" class="rd-btn" style="text-decoration:none;margin-left:auto">← About rVote</a>
|
||||
<!-- ELI5: How rVote Works -->
|
||||
<div style="margin-bottom:2rem">
|
||||
<h2 style="text-align:center;font-size:1.1rem;font-weight:600;color:#cbd5e1;margin-bottom:1rem">How rVote Works</h2>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.75rem">
|
||||
<div class="rd-card" style="border:2px solid rgba(249,115,22,0.35);background:linear-gradient(to bottom right,rgba(249,115,22,0.08),rgba(249,115,22,0.03));padding:1rem">
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;margin-bottom:0.5rem">
|
||||
<div style="width:1.5rem;height:1.5rem;border-radius:9999px;background:#f97316;display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
||||
<span style="color:white;font-weight:700;font-size:0.65rem">x²</span>
|
||||
</div>
|
||||
<h3 style="color:#fb923c;font-size:0.9rem;margin:0">Quadratic</h3>
|
||||
</div>
|
||||
<p style="font-size:0.8rem;margin:0;line-height:1.5">
|
||||
Voting more costs exponentially. 1 vote = 1 credit, 2 = 4, 3 = 9.
|
||||
<strong style="display:block;margin-top:0.35rem;color:#e2e8f0;font-size:0.78rem">No single voice can dominate.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="rd-card" style="border:2px solid rgba(59,130,246,0.35);background:linear-gradient(to bottom right,rgba(59,130,246,0.08),rgba(59,130,246,0.03));padding:1rem">
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;margin-bottom:0.5rem">
|
||||
<div style="width:1.5rem;height:1.5rem;border-radius:9999px;background:#3b82f6;display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>
|
||||
</div>
|
||||
<h3 style="color:#60a5fa;font-size:0.9rem;margin:0">Reddit-style</h3>
|
||||
</div>
|
||||
<p style="font-size:0.8rem;margin:0;line-height:1.5">
|
||||
Upvote or downvote proposals. Scores aggregate from all votes.
|
||||
<strong style="display:block;margin-top:0.35rem;color:#e2e8f0;font-size:0.78rem">Best ideas rise to the top.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="rd-card" style="border:2px solid rgba(168,85,247,0.35);background:linear-gradient(to bottom right,rgba(168,85,247,0.08),rgba(168,85,247,0.03));padding:1rem">
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;margin-bottom:0.5rem">
|
||||
<div style="width:1.5rem;height:1.5rem;border-radius:9999px;background:#a855f7;display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
</div>
|
||||
<h3 style="color:#c084fc;font-size:0.9rem;margin:0">Vote Decay</h3>
|
||||
</div>
|
||||
<p style="font-size:0.8rem;margin:0;line-height:1.5">
|
||||
Votes fade after 30–60 days. Old support expires naturally.
|
||||
<strong style="display:block;margin-top:0.35rem;color:#e2e8f0;font-size:0.78rem">Rankings stay fresh.</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conviction Voting Simulator -->
|
||||
<div style="margin-bottom:2.5rem">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem">
|
||||
<div>
|
||||
<span class="rd-badge rd-badge--blue" style="margin-bottom:0.25rem">Try It</span>
|
||||
<h2 style="font-size:1.1rem;font-weight:600;color:#cbd5e1;margin:0.25rem 0 0">Conviction Voting Simulator</h2>
|
||||
</div>
|
||||
<div id="rd-credit-budget" style="display:flex;align-items:center;gap:0.5rem;background:#0f172a;border:1px solid #1e293b;border-radius:8px;padding:0.4rem 0.75rem">
|
||||
<span style="font-size:0.75rem;color:#64748b">Your credits:</span>
|
||||
<span id="rd-credits" style="font-weight:700;color:#22c55e;font-family:monospace;font-size:0.95rem">50</span>
|
||||
<span style="font-size:0.7rem;color:#475569">/ 50</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size:0.8rem;color:#64748b;margin:0 0 1rem">
|
||||
Upvote proposals to rank them. Each additional vote costs quadratically more credits (1, 4, 9, 16...). Proposals re-rank by score in real-time.
|
||||
</p>
|
||||
<div id="rd-sim-proposals"></div>
|
||||
<div style="text-align:center;margin-top:0.75rem">
|
||||
<button id="rd-sim-reset" class="rd-btn" style="font-size:0.75rem">Reset Simulator</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Synced Polls -->
|
||||
<div style="margin-bottom:0.5rem">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem">
|
||||
<div>
|
||||
<span class="rd-badge rd-badge--green" style="margin-bottom:0.25rem">Live</span>
|
||||
<h2 style="font-size:1.1rem;font-weight:600;color:#cbd5e1;margin:0.25rem 0 0">Community Polls</h2>
|
||||
</div>
|
||||
<div class="rd-toolbar" style="margin:0">
|
||||
<span id="rd-conn-badge" class="rd-status rd-status--disconnected">Connecting</span>
|
||||
<button id="rd-reset-btn" class="rd-btn" disabled style="font-size:0.75rem">Reset Demo</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size:0.8rem;color:#64748b;margin:0 0 1rem">
|
||||
These polls sync across all r* apps in real-time via WebSocket.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="rd-loading" class="rd-loading">
|
||||
|
|
|
|||
Loading…
Reference in New Issue