rspace-online/modules/rvote/mod.ts

698 lines
27 KiB
TypeScript

/**
* Vote module — conviction voting engine.
*
* Credit-weighted conviction voting for collaborative governance.
* Spaces run ranked proposals with configurable parameters.
*
* All state stored in Automerge documents via SyncServer.
* Doc layout:
* {space}:vote:config → SpaceConfig (stored on a ProposalDoc)
* {space}:vote:proposals:{proposalId} → ProposalDoc with votes/finalVotes
*/
import { Hono } from "hono";
import * as Automerge from '@automerge/automerge';
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server';
import { proposalSchema, proposalDocId } from './schemas';
import type { ProposalDoc, SpaceConfig } from './schemas';
const routes = new Hono();
// ── SyncServer ref (set during onInit) ──
let _syncServer: SyncServer | null = null;
// ── DocId helpers ──
function spaceConfigDocId(space: string) {
return `${space}:vote:config` as const;
}
// ── Automerge helpers ──
/** Ensure a proposal doc exists, creating it if needed. */
function ensureProposalDoc(space: string, proposalId: string): ProposalDoc {
const docId = proposalDocId(space, proposalId);
let doc = _syncServer!.getDoc<ProposalDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ProposalDoc>(), 'init proposal', (d) => {
const init = proposalSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
d.proposal.id = proposalId;
d.proposal.spaceSlug = space;
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
/** Ensure a space config doc exists, creating it if needed. Returns the doc. */
function ensureSpaceConfigDoc(space: string): ProposalDoc {
const docId = spaceConfigDocId(space);
let doc = _syncServer!.getDoc<ProposalDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ProposalDoc>(), 'init space config', (d) => {
const init = proposalSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
d.spaceConfig = {
slug: space,
name: '',
description: '',
ownerDid: '',
visibility: 'public',
promotionThreshold: 100,
votingPeriodDays: 7,
creditsPerDay: 10,
maxCredits: 500,
startingCredits: 50,
createdAt: Date.now(),
updatedAt: Date.now(),
};
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
/** Get all space config docs across all spaces. */
function listSpaceConfigDocs(): { docId: string; doc: ProposalDoc }[] {
if (!_syncServer) return [];
const results: { docId: string; doc: ProposalDoc }[] = [];
for (const docId of _syncServer.listDocs()) {
if (docId.endsWith(':vote:config')) {
const doc = _syncServer.getDoc<ProposalDoc>(docId);
if (doc?.spaceConfig) results.push({ docId, doc });
}
}
return results;
}
/** Get all proposal docs for a space. */
function listProposalDocs(space: string): { docId: string; doc: ProposalDoc }[] {
if (!_syncServer) return [];
const results: { docId: string; doc: ProposalDoc }[] = [];
const prefix = `${space}:vote:proposals:`;
for (const docId of _syncServer.listDocs()) {
if (docId.startsWith(prefix)) {
const doc = _syncServer.getDoc<ProposalDoc>(docId);
if (doc) results.push({ docId, doc });
}
}
return results;
}
/** Get all proposal docs across all spaces. */
function listAllProposalDocs(): { docId: string; doc: ProposalDoc }[] {
if (!_syncServer) return [];
const results: { docId: string; doc: ProposalDoc }[] = [];
for (const docId of _syncServer.listDocs()) {
if (docId.includes(':vote:proposals:')) {
const doc = _syncServer.getDoc<ProposalDoc>(docId);
if (doc) results.push({ docId, doc });
}
}
return results;
}
// ── Conversion helpers (Automerge → REST format) ──
function spaceConfigToRest(cfg: SpaceConfig) {
return {
slug: cfg.slug,
name: cfg.name,
description: cfg.description,
owner_did: cfg.ownerDid,
visibility: cfg.visibility,
promotion_threshold: cfg.promotionThreshold,
voting_period_days: cfg.votingPeriodDays,
credits_per_day: cfg.creditsPerDay,
max_credits: cfg.maxCredits,
starting_credits: cfg.startingCredits,
created_at: new Date(cfg.createdAt).toISOString(),
updated_at: new Date(cfg.updatedAt).toISOString(),
};
}
function proposalToRest(doc: ProposalDoc) {
const p = doc.proposal;
const voteCount = Object.keys(doc.votes).length;
return {
id: p.id,
space_slug: p.spaceSlug,
author_id: p.authorId,
title: p.title,
description: p.description,
status: p.status,
score: p.score,
voting_ends_at: p.votingEndsAt ? new Date(p.votingEndsAt).toISOString() : null,
final_yes: p.finalYes,
final_no: p.finalNo,
final_abstain: p.finalAbstain,
vote_count: String(voteCount),
created_at: new Date(p.createdAt).toISOString(),
updated_at: new Date(p.updatedAt).toISOString(),
};
}
// ── Helper: calculate effective weight with decay ──
function getEffectiveWeight(weight: number, createdAt: number): number {
const ageMs = Date.now() - createdAt;
const ageDays = ageMs / (1000 * 60 * 60 * 24);
if (ageDays < 30) return weight;
if (ageDays >= 60) return 0;
const decayProgress = (ageDays - 30) / 30;
return Math.round(weight * (1 - decayProgress));
}
// ── Helper: recalculate proposal score from votes ──
function recalcScore(doc: ProposalDoc): number {
let score = 0;
for (const v of Object.values(doc.votes)) {
score += getEffectiveWeight(v.weight, v.createdAt);
}
return score;
}
// ── Helper: generate unique ID ──
function newId(): string {
return crypto.randomUUID();
}
// ── Seed demo data into Automerge ──
function seedDemoIfEmpty(space: string = 'community') {
if (!_syncServer) return;
// If this space already has proposals, skip
if (listProposalDocs(space).length > 0) return;
// Ensure space config exists
ensureSpaceConfigDoc(space);
_syncServer!.changeDoc<ProposalDoc>(spaceConfigDocId(space), 'seed space config', (d) => {
if (d.spaceConfig!.name) return; // already configured
d.spaceConfig!.name = 'Community Governance';
d.spaceConfig!.description = 'Proposals for the rSpace ecosystem';
d.spaceConfig!.ownerDid = 'did:demo:seed';
d.spaceConfig!.promotionThreshold = 100;
d.spaceConfig!.votingPeriodDays = 7;
d.spaceConfig!.creditsPerDay = 10;
d.spaceConfig!.maxCredits = 500;
d.spaceConfig!.startingCredits = 50;
});
const demoUserId = 'did:demo:seed';
const now = Date.now();
const proposals = [
{ title: "Add dark mode across all r* modules", desc: "Implement a consistent dark theme with a toggle in shell.css. Use CSS custom properties for theming so each module inherits automatically.", status: "RANKING", score: 45 },
{ title: "Implement real-time collaboration in rNotes", desc: "Use Automerge CRDTs (already in the stack) to enable simultaneous editing of notes, similar to how rSpace canvas works.", status: "RANKING", score: 72 },
{ title: "Adopt cosmolocal print-on-demand for all merch", desc: "Route all merchandise orders through the provider registry to find the closest printer. Reduces shipping emissions and supports local economies.", status: "VOTING", score: 105 },
{ title: "Use EncryptID passkeys for all authentication", desc: "Standardize on WebAuthn passkeys via EncryptID across the entire r* ecosystem. One passkey, all apps.", status: "PASSED", score: 150 },
{ title: "Switch from PostgreSQL to SQLite for simpler deployment", desc: "Evaluate replacing PostgreSQL with SQLite for modules that don't need concurrent writes.", status: "FAILED", score: 30 },
];
for (const p of proposals) {
const pid = newId();
const docId = proposalDocId(space, pid);
let doc = Automerge.change(Automerge.init<ProposalDoc>(), 'seed proposal', (d) => {
const init = proposalSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
d.proposal.id = pid;
d.proposal.spaceSlug = space;
d.proposal.authorId = demoUserId;
d.proposal.title = p.title;
d.proposal.description = p.desc;
d.proposal.status = p.status;
d.proposal.score = p.score;
d.proposal.createdAt = now;
d.proposal.updatedAt = now;
});
if (p.status === "VOTING") {
doc = Automerge.change(doc, 'set voting tally', (d) => {
d.proposal.votingEndsAt = now + 5 * 24 * 60 * 60 * 1000;
d.proposal.finalYes = 5;
d.proposal.finalNo = 2;
});
} else if (p.status === "PASSED") {
doc = Automerge.change(doc, 'set passed tally', (d) => {
d.proposal.finalYes = 12;
d.proposal.finalNo = 3;
d.proposal.finalAbstain = 2;
});
} else if (p.status === "FAILED") {
doc = Automerge.change(doc, 'set failed tally', (d) => {
d.proposal.finalYes = 2;
d.proposal.finalNo = 8;
d.proposal.finalAbstain = 1;
});
}
_syncServer!.setDoc(docId, doc);
}
console.log(`[Vote] Demo data seeded for "${space}": 1 space config, 5 proposals`);
}
// ── Spaces API ──
// GET /api/spaces — list spaces
routes.get("/api/spaces", (c) => {
const spaceDocs = listSpaceConfigDocs();
const spaces = spaceDocs
.filter((s) => s.doc.spaceConfig !== null)
.map((s) => spaceConfigToRest(s.doc.spaceConfig!))
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 50);
return c.json({ spaces });
});
// POST /api/spaces — create space
routes.post("/api/spaces", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json();
const { name, slug, description, visibility = "public" } = body;
if (!name || !slug) return c.json({ error: "name and slug required" }, 400);
if (!/^[a-z0-9-]+$/.test(slug)) return c.json({ error: "Invalid slug" }, 400);
// Check if space already exists
const existing = _syncServer!.getDoc<ProposalDoc>(spaceConfigDocId(slug));
if (existing?.spaceConfig?.name) return c.json({ error: "Space already exists" }, 409);
const now = Date.now();
const doc = ensureSpaceConfigDoc(slug);
_syncServer!.changeDoc<ProposalDoc>(spaceConfigDocId(slug), 'create space', (d) => {
d.spaceConfig!.slug = slug;
d.spaceConfig!.name = name;
d.spaceConfig!.description = description || '';
d.spaceConfig!.ownerDid = claims.sub;
d.spaceConfig!.visibility = visibility;
d.spaceConfig!.createdAt = now;
d.spaceConfig!.updatedAt = now;
});
const updated = _syncServer!.getDoc<ProposalDoc>(spaceConfigDocId(slug));
return c.json(spaceConfigToRest(updated!.spaceConfig!), 201);
});
// GET /api/spaces/:slug — space detail
routes.get("/api/spaces/:slug", (c) => {
const slug = c.req.param("slug");
const doc = _syncServer!.getDoc<ProposalDoc>(spaceConfigDocId(slug));
if (!doc?.spaceConfig?.name) return c.json({ error: "Space not found" }, 404);
return c.json(spaceConfigToRest(doc.spaceConfig));
});
// ── Proposals API ──
// GET /api/proposals — list proposals (query: space_slug, status)
routes.get("/api/proposals", (c) => {
const { space_slug, status, limit = "50", offset = "0" } = c.req.query();
const maxLimit = Math.min(parseInt(limit) || 50, 100);
const startOffset = parseInt(offset) || 0;
let docs: { docId: string; doc: ProposalDoc }[];
if (space_slug) {
docs = listProposalDocs(space_slug);
} else {
docs = listAllProposalDocs();
}
let proposals = docs
.filter((d) => d.doc.proposal.title) // exclude empty/config docs
.map((d) => proposalToRest(d.doc));
if (status) {
proposals = proposals.filter((p) => p.status === status);
}
// Sort by score descending, then created_at descending
proposals.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
proposals = proposals.slice(startOffset, startOffset + maxLimit);
return c.json({ proposals });
});
// POST /api/proposals — create proposal
routes.post("/api/proposals", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json();
const { space_slug, title, description } = body;
if (!space_slug || !title) return c.json({ error: "space_slug and title required" }, 400);
// Verify space exists
const spaceDoc = _syncServer!.getDoc<ProposalDoc>(spaceConfigDocId(space_slug));
if (!spaceDoc?.spaceConfig?.name) return c.json({ error: "Space not found" }, 404);
const pid = newId();
const now = Date.now();
const docId = proposalDocId(space_slug, pid);
const doc = Automerge.change(Automerge.init<ProposalDoc>(), 'create proposal', (d) => {
const init = proposalSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space_slug;
d.proposal.id = pid;
d.proposal.spaceSlug = space_slug;
d.proposal.authorId = claims.sub;
d.proposal.title = title;
d.proposal.description = description || '';
d.proposal.createdAt = now;
d.proposal.updatedAt = now;
});
_syncServer!.setDoc(docId, doc);
return c.json(proposalToRest(doc), 201);
});
// GET /api/proposals/:id — proposal detail
routes.get("/api/proposals/:id", (c) => {
const id = c.req.param("id");
// Search across all spaces for this proposal
const allDocs = listAllProposalDocs();
const match = allDocs.find((d) => d.doc.proposal.id === id);
if (!match) return c.json({ error: "Proposal not found" }, 404);
return c.json(proposalToRest(match.doc));
});
// POST /api/proposals/:id/vote — cast conviction vote
routes.post("/api/proposals/:id/vote", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const id = c.req.param("id");
const body = await c.req.json();
const { weight = 1 } = body;
// Find proposal
const allDocs = listAllProposalDocs();
const match = allDocs.find((d) => d.doc.proposal.id === id);
if (!match) return c.json({ error: "Proposal not found" }, 404);
if (match.doc.proposal.status !== "RANKING") return c.json({ error: "Proposal not in ranking phase" }, 400);
const userId = claims.sub;
const creditCost = weight * weight; // quadratic cost
const now = Date.now();
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
// Upsert vote (keyed by userId)
_syncServer!.changeDoc<ProposalDoc>(match.docId, 'cast conviction vote', (d) => {
d.votes[userId] = {
id: d.votes[userId]?.id || newId(),
userId,
proposalId: id,
weight,
creditCost,
createdAt: now,
decaysAt: now + thirtyDaysMs,
};
});
// Re-read doc, recalculate score
const updatedDoc = _syncServer!.getDoc<ProposalDoc>(match.docId)!;
const score = recalcScore(updatedDoc);
// Update score on the doc
_syncServer!.changeDoc<ProposalDoc>(match.docId, 'update score', (d) => {
d.proposal.score = score;
d.proposal.updatedAt = Date.now();
});
// Check for promotion to VOTING phase
const spaceSlug = updatedDoc.proposal.spaceSlug;
const spaceDoc = _syncServer!.getDoc<ProposalDoc>(spaceConfigDocId(spaceSlug));
const threshold = spaceDoc?.spaceConfig?.promotionThreshold || 100;
if (score >= threshold && updatedDoc.proposal.status === "RANKING") {
const votingDays = spaceDoc?.spaceConfig?.votingPeriodDays || 7;
const votingEndsAt = Date.now() + votingDays * 24 * 60 * 60 * 1000;
_syncServer!.changeDoc<ProposalDoc>(match.docId, 'promote to voting', (d) => {
d.proposal.status = 'VOTING';
d.proposal.votingEndsAt = votingEndsAt;
d.proposal.updatedAt = Date.now();
});
}
return c.json({ ok: true, score, creditCost });
});
// POST /api/proposals/:id/final-vote — cast binary vote
routes.post("/api/proposals/:id/final-vote", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const id = c.req.param("id");
const body = await c.req.json();
const { vote } = body;
if (!["YES", "NO", "ABSTAIN"].includes(vote)) return c.json({ error: "Invalid vote" }, 400);
// Find proposal
const allDocs = listAllProposalDocs();
const match = allDocs.find((d) => d.doc.proposal.id === id);
if (!match) return c.json({ error: "Proposal not found" }, 404);
if (match.doc.proposal.status !== "VOTING") return c.json({ error: "Proposal not in voting phase" }, 400);
const userId = claims.sub;
const now = Date.now();
// Upsert final vote (keyed by userId)
_syncServer!.changeDoc<ProposalDoc>(match.docId, 'cast final vote', (d) => {
d.finalVotes[userId] = {
id: d.finalVotes[userId]?.id || newId(),
userId,
proposalId: id,
vote: vote as 'YES' | 'NO' | 'ABSTAIN',
createdAt: now,
};
});
// Tally final votes
const updatedDoc = _syncServer!.getDoc<ProposalDoc>(match.docId)!;
const tally: Record<string, number> = { YES: 0, NO: 0, ABSTAIN: 0 };
for (const fv of Object.values(updatedDoc.finalVotes)) {
tally[fv.vote] = (tally[fv.vote] || 0) + 1;
}
_syncServer!.changeDoc<ProposalDoc>(match.docId, 'update final tally', (d) => {
d.proposal.finalYes = tally.YES;
d.proposal.finalNo = tally.NO;
d.proposal.finalAbstain = tally.ABSTAIN;
d.proposal.updatedAt = Date.now();
});
return c.json({ ok: true, tally });
});
// ── Page routes ──
// Demo page — interactive polls with live sync
routes.get("/demo", (c) => {
return c.html(renderShell({
title: "rVote Demo — Interactive Polls | rSpace",
moduleId: "rvote",
spaceSlug: "demo",
modules: getModuleInfoList(),
theme: "dark",
body: `
<div class="rd-page">
<div class="rd-hero">
<span class="rl-badge" style="background:#1e293b;color:#94a3b8;font-size:0.7rem;padding:0.25rem 0.75rem;display:inline-block;margin-bottom:0.75rem;border-radius:9999px">Interactive Demo</span>
<h1>Live Community Polls</h1>
<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>
<!-- 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&sup2;</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&ndash;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">
<div class="rd-loading-spinner"></div>
Connecting to rSpace...
</div>
<div id="rd-empty" class="rd-empty" style="display:none">
<div class="rd-empty-icon">🗳</div>
No polls found. Click <strong>Reset Demo</strong> to seed some sample polls.
</div>
<div id="rd-polls-container"></div>
<!-- CTA Card -->
<div style="margin:2.5rem auto;max-width:640px">
<div class="rl-card" style="border:2px solid rgba(249,115,22,0.25);background:linear-gradient(to bottom right,rgba(249,115,22,0.08),rgba(249,115,22,0.03));text-align:center;padding:2rem 1.5rem">
<h2 style="font-size:1.25rem;font-weight:700;color:#e2e8f0;margin:0 0 0.75rem">Ready to try it for real?</h2>
<p style="color:#94a3b8;max-width:480px;margin:0 auto 1.5rem;line-height:1.6;font-size:0.95rem">
Create a Space for your community to start ranking and voting on proposals. Invite members and allot credits to get started.
</p>
<div style="display:flex;gap:0.75rem;justify-content:center;flex-wrap:wrap">
<a href="/rids" class="rl-cta-primary" style="background:linear-gradient(to right,#f97316,#ea580c);color:white;text-decoration:none;padding:0.6rem 1.5rem;border-radius:0.5rem;font-weight:600;font-size:0.875rem">Create Account</a>
<a href="/rvote" class="rl-cta-secondary" style="text-decoration:none;padding:0.6rem 1.5rem;border-radius:0.5rem;font-weight:600;font-size:0.875rem;border:1px solid rgba(148,163,184,0.3);color:#94a3b8">Learn More</a>
</div>
</div>
</div>
<div class="rd-footer">
<a href="/rvote">&larr; Back to rVote</a>
</div>
</div>`,
scripts: `<script type="module" src="/modules/rvote/vote-demo.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rvote/vote.css">`,
}));
});
// Dashboard — full voting app with spaces, proposals, conviction voting
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Vote | rSpace`,
moduleId: "rvote",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-vote-dashboard space="${space}"></folk-vote-dashboard>`,
scripts: `<script type="module" src="/modules/rvote/folk-vote-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rvote/vote.css">`,
}));
});
export const voteModule: RSpaceModule = {
id: "rvote",
name: "rVote",
icon: "🗳",
description: "Conviction voting engine for collaborative governance",
scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [{ pattern: '{space}:vote:proposals:{proposalId}', description: 'Proposal with votes', init: proposalSchema.init }],
routes,
standaloneDomain: "rvote.online",
landingPage: renderLanding,
seedTemplate: seedDemoIfEmpty,
async onInit(ctx) {
_syncServer = ctx.syncServer;
seedDemoIfEmpty();
},
feeds: [
{
id: "proposals",
name: "Proposals",
kind: "governance",
description: "Active proposals with conviction scores and vote tallies",
filterable: true,
},
{
id: "decisions",
name: "Decision Outcomes",
kind: "governance",
description: "Passed and failed proposals — governance decisions made",
},
],
acceptsFeeds: ["economic", "data"],
outputPaths: [
{ path: "proposals", name: "Proposals", icon: "📜", description: "Governance proposals for conviction voting" },
{ path: "ballots", name: "Ballots", icon: "🗳️", description: "Voting ballots and results" },
],
onboardingActions: [
{ label: "Create a Proposal", icon: "🗳️", description: "Start a governance vote", type: 'create', href: '/{space}/rvote' },
],
};