feat(rvote): enhanced landing, demo page, and dashboard to match rvote.online quality

- Add /demo route with interactive poll page (connection status, reset, live sync)
- Full rd-* CSS system for demo poll cards, badges, loading states
- Fix landing page links: demo.rspace.online/rvote → /rvote/demo
- Enhanced folk-vote-dashboard: inline voting on proposal cards, grouped by
  status (voting/ranking/decided), create-proposal form, tally bars,
  downvote support, richer visual design with progress indicators
- Add vote-demo.ts build step in vite.config.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-09 23:42:57 -07:00
parent a5c7bb784e
commit 192659b49c
5 changed files with 678 additions and 149 deletions

View File

@ -2,6 +2,7 @@
* <folk-vote-dashboard> conviction voting dashboard.
*
* Browse spaces, create/view proposals, cast votes (ranking + final).
* Enhanced: inline voting, create proposal form, richer UI.
*/
import { proposalSchema, type ProposalDoc } from "../schemas";
@ -43,6 +44,7 @@ class FolkVoteDashboard extends HTMLElement {
private selectedProposal: Proposal | null = null;
private loading = false;
private error = "";
private showCreateForm = false;
private _offlineUnsubs: (() => void)[] = [];
constructor() {
@ -228,14 +230,13 @@ class FolkVoteDashboard extends HTMLElement {
if (p) {
p.score += weight;
p.vote_count = String(parseInt(p.vote_count) + 1);
// Auto-promote if score reaches threshold (matches server-side behavior)
const threshold = this.selectedSpace?.promotion_threshold || 100;
if (p.score >= threshold && p.status === "RANKING") {
p.status = "VOTING";
const votingDays = this.selectedSpace?.voting_period_days || 7;
p.voting_ends_at = new Date(Date.now() + votingDays * 86400000).toISOString();
}
this.selectedProposal = p;
if (this.selectedProposal?.id === proposalId) this.selectedProposal = p;
}
this.render();
return;
@ -247,7 +248,8 @@ class FolkVoteDashboard extends HTMLElement {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ weight }),
});
await this.loadProposal(proposalId);
if (this.view === "proposal") await this.loadProposal(proposalId);
else if (this.selectedSpace) await this.loadProposals(this.selectedSpace.slug);
} catch {
this.error = "Failed to cast vote";
this.render();
@ -261,7 +263,7 @@ class FolkVoteDashboard extends HTMLElement {
if (vote === "YES") p.final_yes++;
else if (vote === "NO") p.final_no++;
else if (vote === "ABSTAIN") p.final_abstain++;
this.selectedProposal = p;
if (this.selectedProposal?.id === proposalId) this.selectedProposal = p;
}
this.render();
return;
@ -273,13 +275,47 @@ class FolkVoteDashboard extends HTMLElement {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vote }),
});
await this.loadProposal(proposalId);
if (this.view === "proposal") await this.loadProposal(proposalId);
else if (this.selectedSpace) await this.loadProposals(this.selectedSpace.slug);
} catch {
this.error = "Failed to cast vote";
this.render();
}
}
private async createProposal(title: string, description: string) {
if (this.space === "demo") {
const now = Date.now();
this.proposals.unshift({
id: `p${now}`,
title,
description,
status: "RANKING",
score: 0,
vote_count: "0",
final_yes: 0, final_no: 0, final_abstain: 0,
created_at: new Date(now).toISOString(),
voting_ends_at: null,
});
this.showCreateForm = false;
this.render();
return;
}
try {
const base = this.getApiBase();
await fetch(`${base}/api/proposals`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ space_slug: this.selectedSpace?.slug, title, description }),
});
this.showCreateForm = false;
if (this.selectedSpace) await this.loadProposals(this.selectedSpace.slug);
} catch {
this.error = "Failed to create proposal";
this.render();
}
}
private getStatusColor(status: string): string {
switch (status) {
case "RANKING": return "#3b82f6";
@ -291,53 +327,150 @@ class FolkVoteDashboard extends HTMLElement {
}
}
private getStatusIcon(status: string): string {
switch (status) {
case "RANKING": return "📊";
case "VOTING": return "🗳";
case "PASSED": return "✅";
case "FAILED": return "❌";
case "ARCHIVED": return "📦";
default: return "📄";
}
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e2e8f0; }
* { box-sizing: border-box; }
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; }
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
.header-back { padding: 6px 12px; border-radius: 8px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; transition: all 0.15s; }
.header-back:hover { color: #e2e8f0; border-color: #818cf8; }
.header-title { font-size: 18px; font-weight: 700; flex: 1; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.header-sub { font-size: 13px; color: #64748b; }
.card {
background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
padding: 16px; margin-bottom: 12px; cursor: pointer; transition: border-color 0.2s;
/* Space cards */
.space-card {
background: #0f172a; border: 1px solid #1e293b; border-radius: 12px;
padding: 20px; margin-bottom: 12px; cursor: pointer; transition: all 0.2s;
}
.card:hover { border-color: #555; }
.card-title { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
.card-desc { font-size: 13px; color: #888; }
.card-meta { display: flex; gap: 12px; margin-top: 8px; font-size: 12px; color: #666; }
.space-card:hover { border-color: #818cf8; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(99,102,241,0.1); }
.space-name { font-size: 18px; font-weight: 700; margin-bottom: 4px; }
.space-desc { font-size: 13px; color: #94a3b8; margin-bottom: 12px; }
.space-meta { display: flex; gap: 16px; flex-wrap: wrap; }
.space-stat { display: flex; flex-direction: column; gap: 2px; }
.space-stat-value { font-size: 15px; font-weight: 600; color: #818cf8; }
.space-stat-label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
/* Proposal cards */
.proposal {
background: #0f172a; border: 1px solid #1e293b; border-radius: 12px;
padding: 16px 20px; margin-bottom: 10px; transition: border-color 0.2s;
}
.proposal:hover { border-color: #334155; }
.proposal-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 6px; }
.proposal-title { font-size: 15px; font-weight: 600; color: #e2e8f0; cursor: pointer; line-height: 1.4; }
.proposal-title:hover { color: #818cf8; }
.proposal-desc { font-size: 13px; color: #64748b; margin-bottom: 10px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
/* Status badge */
.badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 10px; border-radius: 6px;
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.03em; white-space: nowrap; flex-shrink: 0;
}
.score-bar { height: 6px; border-radius: 3px; background: #333; margin-top: 6px; overflow: hidden; }
.score-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
/* Score / progress */
.score-row { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.score-label { font-size: 13px; color: #94a3b8; white-space: nowrap; }
.score-value { font-weight: 700; font-variant-numeric: tabular-nums; }
.score-bar { flex: 1; height: 8px; border-radius: 4px; background: #1e293b; overflow: hidden; min-width: 100px; }
.score-fill { height: 100%; border-radius: 4px; transition: width 0.4s ease; }
.vote-controls { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
/* Vote buttons */
.vote-row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.vote-btn {
padding: 8px 16px; border-radius: 8px; border: 2px solid #333;
background: #1e1e2e; color: #ccc; cursor: pointer; font-size: 14px;
transition: all 0.2s;
padding: 6px 14px; border-radius: 8px; border: 1px solid #334155;
background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 13px;
font-weight: 500; transition: all 0.15s; white-space: nowrap;
}
.vote-btn:hover { border-color: #6366f1; }
.vote-btn.yes { border-color: #22c55e; color: #22c55e; }
.vote-btn.no { border-color: #ef4444; color: #ef4444; }
.vote-btn.abstain { border-color: #f59e0b; color: #f59e0b; }
.vote-btn:hover { border-color: #818cf8; color: #e2e8f0; background: rgba(129,140,248,0.1); }
.vote-btn:active { transform: scale(0.97); }
.vote-btn.yes { border-color: rgba(34,197,94,0.4); color: #22c55e; }
.vote-btn.yes:hover { background: rgba(34,197,94,0.1); border-color: #22c55e; }
.vote-btn.no { border-color: rgba(239,68,68,0.4); color: #ef4444; }
.vote-btn.no:hover { background: rgba(239,68,68,0.1); border-color: #ef4444; }
.vote-btn.abstain { border-color: rgba(245,158,11,0.4); color: #f59e0b; }
.vote-btn.abstain:hover { background: rgba(245,158,11,0.1); border-color: #f59e0b; }
.vote-sep { color: #334155; font-size: 12px; }
.tally { display: flex; gap: 16px; margin-top: 12px; }
/* Tally */
.tally { display: flex; gap: 20px; margin: 10px 0 8px; }
.tally-item { text-align: center; }
.tally-value { font-size: 24px; font-weight: 700; }
.tally-label { font-size: 11px; color: #888; text-transform: uppercase; }
.tally-value { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; }
.tally-label { font-size: 11px; color: #64748b; text-transform: uppercase; }
.tally-bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: #1e293b; margin-top: 6px; }
.tally-bar-yes { background: #22c55e; transition: width 0.3s; }
.tally-bar-no { background: #ef4444; transition: width 0.3s; }
.tally-bar-abstain { background: #f59e0b; transition: width 0.3s; }
.empty { text-align: center; color: #666; padding: 40px; }
.loading { text-align: center; color: #888; padding: 40px; }
.error { text-align: center; color: #ef5350; padding: 20px; }
/* Meta row */
.meta { display: flex; gap: 16px; font-size: 12px; color: #475569; margin-top: 8px; flex-wrap: wrap; }
.meta span { white-space: nowrap; }
/* Create form */
.create-form { background: #0f172a; border: 2px solid #818cf8; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
.create-form h3 { margin: 0 0 12px; font-size: 15px; font-weight: 600; color: #818cf8; }
.form-field { margin-bottom: 12px; }
.form-label { display: block; font-size: 12px; color: #94a3b8; margin-bottom: 4px; font-weight: 500; }
.form-input, .form-textarea {
width: 100%; padding: 10px 12px; border-radius: 8px;
border: 1px solid #334155; background: #1e293b; color: #e2e8f0;
font-size: 14px; font-family: inherit; transition: border-color 0.15s;
}
.form-input:focus, .form-textarea:focus { outline: none; border-color: #818cf8; }
.form-textarea { resize: vertical; min-height: 80px; }
.form-actions { display: flex; gap: 8px; }
.btn-primary {
padding: 8px 20px; border-radius: 8px; border: none;
background: linear-gradient(to right, #818cf8, #6366f1); color: white;
font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity 0.15s;
}
.btn-primary:hover { opacity: 0.9; }
.btn-ghost {
padding: 8px 16px; border-radius: 8px; border: 1px solid #334155;
background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px;
}
.btn-ghost:hover { border-color: #475569; color: #e2e8f0; }
.btn-new {
padding: 8px 16px; border-radius: 8px; border: 1px dashed #334155;
background: transparent; color: #818cf8; cursor: pointer; font-size: 13px;
font-weight: 500; transition: all 0.15s;
}
.btn-new:hover { border-color: #818cf8; background: rgba(129,140,248,0.05); }
/* Detail view */
.detail { background: #0f172a; border: 1px solid #1e293b; border-radius: 12px; padding: 24px; }
.detail-title { font-size: 20px; font-weight: 700; margin-bottom: 8px; }
.detail-desc { font-size: 14px; color: #94a3b8; line-height: 1.6; margin-bottom: 16px; }
.detail-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid #1e293b; }
.detail-section-title { font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 10px; }
.empty { text-align: center; color: #475569; padding: 48px 20px; }
.empty-icon { font-size: 2.5rem; margin-bottom: 8px; }
.empty-text { font-size: 14px; }
.loading { text-align: center; color: #64748b; padding: 48px 20px; }
.error { text-align: center; color: #ef4444; padding: 16px; background: rgba(239,68,68,0.05); border-radius: 8px; margin-bottom: 12px; font-size: 13px; }
.demo-banner {
background: linear-gradient(135deg, rgba(129,140,248,0.08), rgba(192,132,252,0.05));
border: 1px solid rgba(129,140,248,0.2); border-radius: 10px;
padding: 12px 16px; margin-bottom: 16px; display: flex; align-items: center; gap: 10px;
font-size: 13px; color: #94a3b8;
}
.demo-banner strong { color: #818cf8; }
</style>
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
@ -361,23 +494,25 @@ class FolkVoteDashboard extends HTMLElement {
private renderSpaces(): string {
return `
<div class="rapp-nav">
<span class="rapp-nav__title">Voting Spaces</span>
<div class="header">
<span class="header-title">Voting Spaces</span>
</div>
${this.spaces.length === 0 ? '<div class="empty">No voting spaces yet. Create one to get started.</div>' : ""}
${this.spaces.length === 0 ? '<div class="empty"><div class="empty-icon">🗳</div><div class="empty-text">No voting spaces yet. Create one to get started.</div></div>' : ""}
${this.spaces.map((s) => `
<div class="card" data-space="${s.slug}">
<div style="display:flex;justify-content:space-between;align-items:center">
<div class="card-title">${this.esc(s.name)}</div>
<span class="badge" style="background:rgba(129,140,248,0.15);color:#818cf8">${s.visibility === "public" ? "👁 Public" : s.visibility === "permissioned" ? "🔑 Permissioned" : s.visibility === "private" ? "🔒 Private" : s.visibility}</span>
<div class="space-card" data-space="${s.slug}">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<div class="space-name">${this.esc(s.name)}</div>
<span class="badge" style="background:rgba(129,140,248,0.1);color:#818cf8">
${s.visibility === "public" ? "Public" : s.visibility === "permissioned" ? "Permissioned" : "Private"}
</span>
</div>
<div class="card-desc">${this.esc(s.description || "")}</div>
<div class="card-meta">
<span>Threshold: ${s.promotion_threshold}</span>
<span>Voting: ${s.voting_period_days}d</span>
<span>${s.credits_per_day} credits/day</span>
<span>Max: ${s.max_credits}</span>
<span>Start: ${s.starting_credits}</span>
<div class="space-desc">${this.esc(s.description || "")}</div>
<div class="space-meta">
<div class="space-stat"><span class="space-stat-value">${s.promotion_threshold}</span><span class="space-stat-label">Threshold</span></div>
<div class="space-stat"><span class="space-stat-value">${s.voting_period_days}d</span><span class="space-stat-label">Vote Period</span></div>
<div class="space-stat"><span class="space-stat-value">${s.credits_per_day}/day</span><span class="space-stat-label">Credits</span></div>
<div class="space-stat"><span class="space-stat-value">${s.starting_credits}</span><span class="space-stat-label">Starting</span></div>
<div class="space-stat"><span class="space-stat-value">${s.max_credits}</span><span class="space-stat-label">Max</span></div>
</div>
</div>
`).join("")}
@ -387,121 +522,229 @@ class FolkVoteDashboard extends HTMLElement {
private relativeTime(dateStr: string): string {
const daysAgo = Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000);
if (daysAgo === 0) return "today";
if (daysAgo === 1) return "1 day ago";
return `${daysAgo} days ago`;
if (daysAgo === 1) return "yesterday";
return `${daysAgo}d ago`;
}
private daysLeft(dateStr: string | null): string {
if (!dateStr) return "";
const diff = new Date(dateStr).getTime() - Date.now();
if (diff <= 0) return "ended";
const days = Math.ceil(diff / 86400000);
return `${days}d left`;
}
private renderProposals(): string {
const s = this.selectedSpace!;
const threshold = s.promotion_threshold || 100;
// Group proposals by status
const ranking = this.proposals.filter(p => p.status === "RANKING");
const voting = this.proposals.filter(p => p.status === "VOTING");
const decided = this.proposals.filter(p => p.status === "PASSED" || p.status === "FAILED");
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="spaces"> Spaces</button>
<span class="rapp-nav__title">${this.esc(s.name)} Proposals</span>
<div class="header">
${this.spaces.length > 1 ? `<button class="header-back" data-back="spaces">&larr;</button>` : ""}
<span class="header-title">${this.esc(s.name)}</span>
<span class="header-sub">${this.proposals.length} proposal${this.proposals.length !== 1 ? "s" : ""}</span>
<button class="btn-new" data-toggle-create>+ New Proposal</button>
</div>
${this.proposals.length === 0 ? '<div class="empty">No proposals yet.</div>' : ""}
${this.proposals.map((p) => `
<div class="card" data-proposal="${p.id}">
<div style="display:flex;justify-content:space-between;align-items:center">
<div class="card-title">${this.esc(p.title)}</div>
<span class="badge" style="background:${this.getStatusColor(p.status)}20;color:${this.getStatusColor(p.status)}">${p.status}</span>
</div>
<div class="card-desc">${this.esc(p.description || "")}</div>
${p.status === "RANKING" ? `
<div class="score-bar">
<div class="score-fill" style="width:${Math.min(100, (p.score / (s.promotion_threshold || 100)) * 100)}%;background:${this.getStatusColor(p.status)}"></div>
</div>
<div class="card-meta">
<span>Score: ${Math.round(p.score)} / ${s.promotion_threshold}</span>
<span>${p.vote_count} votes</span>
<span>${this.relativeTime(p.created_at)}</span>
</div>
` : ""}
${p.status === "VOTING" ? `
<div class="card-meta">
<span style="color:#22c55e">Yes: ${p.final_yes}</span>
<span style="color:#ef4444">No: ${p.final_no}</span>
<span style="color:#f59e0b">Abstain: ${p.final_abstain}</span>
${p.voting_ends_at ? `<span>Ends: ${new Date(p.voting_ends_at).toLocaleDateString()}</span>` : ""}
</div>
` : ""}
${p.status === "PASSED" || p.status === "FAILED" ? `
<div class="card-meta">
<span style="color:#22c55e">Yes: ${p.final_yes}</span>
<span style="color:#ef4444">No: ${p.final_no}</span>
<span style="color:#f59e0b">Abstain: ${p.final_abstain}</span>
<span>${this.relativeTime(p.created_at)}</span>
</div>
` : ""}
${this.space === "demo" ? `
<div class="demo-banner">
<strong>Demo mode</strong> votes modify local state only. Create a space for real governance.
</div>
`).join("")}
` : ""}
${this.showCreateForm ? this.renderCreateForm() : ""}
${voting.length > 0 ? `
<div style="font-size:12px;font-weight:600;color:#f59e0b;text-transform:uppercase;letter-spacing:0.05em;margin:16px 0 8px;display:flex;align-items:center;gap:6px">
<span>🗳</span> Final Voting (${voting.length})
</div>
${voting.map(p => this.renderProposalCard(p, threshold)).join("")}
` : ""}
${ranking.length > 0 ? `
<div style="font-size:12px;font-weight:600;color:#3b82f6;text-transform:uppercase;letter-spacing:0.05em;margin:16px 0 8px;display:flex;align-items:center;gap:6px">
<span>📊</span> Ranking (${ranking.length})
</div>
${ranking.map(p => this.renderProposalCard(p, threshold)).join("")}
` : ""}
${decided.length > 0 ? `
<div style="font-size:12px;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:0.05em;margin:16px 0 8px;display:flex;align-items:center;gap:6px">
<span>📋</span> Decided (${decided.length})
</div>
${decided.map(p => this.renderProposalCard(p, threshold)).join("")}
` : ""}
${this.proposals.length === 0 && !this.showCreateForm ? '<div class="empty"><div class="empty-icon">📝</div><div class="empty-text">No proposals yet. Create one to get started.</div></div>' : ""}
`;
}
private renderProposalCard(p: Proposal, threshold: number): string {
const statusColor = this.getStatusColor(p.status);
const pct = Math.min(100, (p.score / threshold) * 100);
const totalFinal = p.final_yes + p.final_no + p.final_abstain;
return `
<div class="proposal" data-pid="${p.id}">
<div class="proposal-top">
<span class="proposal-title" data-proposal="${p.id}">${this.esc(p.title)}</span>
<span class="badge" style="background:${statusColor}18;color:${statusColor}">${this.getStatusIcon(p.status)} ${p.status}</span>
</div>
<div class="proposal-desc">${this.esc(p.description || "")}</div>
${p.status === "RANKING" ? `
<div class="score-row">
<span class="score-label">Score: <span class="score-value" style="color:#3b82f6">${Math.round(p.score)}</span> / ${threshold}</span>
<div class="score-bar"><div class="score-fill" style="width:${pct}%;background:linear-gradient(90deg,#3b82f6,#818cf8)"></div></div>
</div>
<div class="vote-row">
${[1, 2, 3, 5].map(w => `<button class="vote-btn" data-vote-weight="${w}" data-vote-id="${p.id}">+${w} <span style="font-size:11px;color:#64748b">(${w * w}cr)</span></button>`).join("")}
<span class="vote-sep">|</span>
${[-1, -2].map(w => `<button class="vote-btn" data-vote-weight="${w}" data-vote-id="${p.id}" style="border-color:rgba(239,68,68,0.3);color:#ef4444">${w}</button>`).join("")}
</div>
` : ""}
${p.status === "VOTING" ? `
<div class="tally">
<div class="tally-item"><div class="tally-value" style="color:#22c55e">${p.final_yes}</div><div class="tally-label">Yes</div></div>
<div class="tally-item"><div class="tally-value" style="color:#ef4444">${p.final_no}</div><div class="tally-label">No</div></div>
<div class="tally-item"><div class="tally-value" style="color:#f59e0b">${p.final_abstain}</div><div class="tally-label">Abstain</div></div>
</div>
${totalFinal > 0 ? `<div class="tally-bar">
<div class="tally-bar-yes" style="width:${(p.final_yes/totalFinal)*100}%"></div>
<div class="tally-bar-no" style="width:${(p.final_no/totalFinal)*100}%"></div>
<div class="tally-bar-abstain" style="width:${(p.final_abstain/totalFinal)*100}%"></div>
</div>` : ""}
<div class="vote-row" style="margin-top:10px">
<button class="vote-btn yes" data-final-vote="YES" data-vote-id="${p.id}">Vote Yes</button>
<button class="vote-btn no" data-final-vote="NO" data-vote-id="${p.id}">Vote No</button>
<button class="vote-btn abstain" data-final-vote="ABSTAIN" data-vote-id="${p.id}">Abstain</button>
</div>
` : ""}
${p.status === "PASSED" || p.status === "FAILED" ? `
<div class="tally">
<div class="tally-item"><div class="tally-value" style="color:#22c55e">${p.final_yes}</div><div class="tally-label">Yes</div></div>
<div class="tally-item"><div class="tally-value" style="color:#ef4444">${p.final_no}</div><div class="tally-label">No</div></div>
<div class="tally-item"><div class="tally-value" style="color:#f59e0b">${p.final_abstain}</div><div class="tally-label">Abstain</div></div>
</div>
${totalFinal > 0 ? `<div class="tally-bar">
<div class="tally-bar-yes" style="width:${(p.final_yes/totalFinal)*100}%"></div>
<div class="tally-bar-no" style="width:${(p.final_no/totalFinal)*100}%"></div>
<div class="tally-bar-abstain" style="width:${(p.final_abstain/totalFinal)*100}%"></div>
</div>` : ""}
` : ""}
<div class="meta">
<span>${p.vote_count} vote${p.vote_count !== "1" ? "s" : ""}</span>
<span>${this.relativeTime(p.created_at)}</span>
${p.status === "VOTING" && p.voting_ends_at ? `<span style="color:#f59e0b">${this.daysLeft(p.voting_ends_at)}</span>` : ""}
${p.status === "RANKING" ? `<span style="color:#3b82f6">${Math.round(threshold - p.score)} to advance</span>` : ""}
</div>
</div>
`;
}
private renderCreateForm(): string {
return `
<div class="create-form">
<h3>New Proposal</h3>
<div class="form-field">
<label class="form-label">Title</label>
<input class="form-input" id="create-title" placeholder="What should we do?" autofocus>
</div>
<div class="form-field">
<label class="form-label">Description</label>
<textarea class="form-textarea" id="create-desc" placeholder="Explain the proposal and why it matters..."></textarea>
</div>
<div class="form-actions">
<button class="btn-primary" data-submit-create>Submit Proposal</button>
<button class="btn-ghost" data-cancel-create>Cancel</button>
</div>
</div>
`;
}
private renderProposal(): string {
const p = this.selectedProposal!;
const threshold = this.selectedSpace?.promotion_threshold || 100;
const created = new Date(p.created_at);
const daysAgo = Math.floor((Date.now() - created.getTime()) / 86400000);
const ageText = daysAgo === 0 ? "today" : daysAgo === 1 ? "1 day ago" : `${daysAgo} days ago`;
const pct = Math.min(100, (p.score / threshold) * 100);
const totalFinal = p.final_yes + p.final_no + p.final_abstain;
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="proposals"> Proposals</button>
<span class="rapp-nav__title">${this.esc(p.title)}</span>
<span class="badge" style="background:${this.getStatusColor(p.status)}20;color:${this.getStatusColor(p.status)};margin-left:8px">${p.status}</span>
<div class="header">
<button class="header-back" data-back="proposals">&larr; Proposals</button>
<span class="header-title">${this.esc(p.title)}</span>
<span class="badge" style="background:${this.getStatusColor(p.status)}18;color:${this.getStatusColor(p.status)}">${this.getStatusIcon(p.status)} ${p.status}</span>
</div>
<div class="card" style="cursor:default">
<div class="card-desc" style="font-size:14px;color:#ccc;margin-bottom:12px">${this.esc(p.description || "No description")}</div>
<div class="card-meta" style="margin-bottom:12px">
<div class="detail">
<div class="detail-desc">${this.esc(p.description || "No description provided.")}</div>
<div class="meta" style="margin-top:0">
<span>${p.vote_count} votes</span>
<span>Created ${ageText}</span>
<span>Created ${this.relativeTime(p.created_at)}</span>
${p.voting_ends_at ? `<span>Voting ${this.daysLeft(p.voting_ends_at)}</span>` : ""}
</div>
${p.status === "RANKING" ? `
<div style="margin-bottom:8px;font-size:13px;color:#888">
Conviction score: <strong style="color:#3b82f6">${Math.round(p.score)}</strong> / ${threshold}
</div>
<div class="score-bar">
<div class="score-fill" style="width:${Math.min(100, (p.score / threshold) * 100)}%;background:#3b82f6"></div>
</div>
<div style="font-size:12px;color:#666;margin-top:4px">
Needs ${threshold} to advance to voting (quadratic cost: weight&sup2; credits)
</div>
<div class="vote-controls">
${[1, 2, 3, 5].map((w) => `
<button class="vote-btn" data-vote-weight="${w}">
+${w} (${w * w} credits)
</button>
`).join("")}
<div class="detail-section">
<div class="detail-section-title">Conviction Score</div>
<div class="score-row">
<span class="score-label"><span class="score-value" style="color:#3b82f6;font-size:24px">${Math.round(p.score)}</span> / ${threshold}</span>
<div class="score-bar" style="height:10px"><div class="score-fill" style="width:${pct}%;background:linear-gradient(90deg,#3b82f6,#818cf8)"></div></div>
</div>
<div style="font-size:12px;color:#475569;margin:8px 0 12px">
Needs ${Math.max(0, Math.round(threshold - p.score))} more to advance to final vote. Cost: weight&sup2; credits.
</div>
<div class="vote-row">
${[1, 2, 3, 5].map(w => `<button class="vote-btn" data-vote-weight="${w}">+${w} (${w * w} credits)</button>`).join("")}
<span class="vote-sep">|</span>
${[-1, -2].map(w => `<button class="vote-btn" data-vote-weight="${w}" style="border-color:rgba(239,68,68,0.3);color:#ef4444">${w}</button>`).join("")}
</div>
</div>
` : ""}
${p.status === "VOTING" ? `
<div style="margin-bottom:8px;font-size:13px;color:#888">
Promoted with conviction score <strong style="color:#f59e0b">${Math.round(p.score)}</strong> now in ${this.selectedSpace?.voting_period_days || 7}-day final vote
<div class="detail-section">
<div class="detail-section-title">Final Vote ${this.selectedSpace?.voting_period_days || 7}-day period</div>
<div class="tally">
<div class="tally-item"><div class="tally-value" style="color:#22c55e">${p.final_yes}</div><div class="tally-label">Yes</div></div>
<div class="tally-item"><div class="tally-value" style="color:#ef4444">${p.final_no}</div><div class="tally-label">No</div></div>
<div class="tally-item"><div class="tally-value" style="color:#f59e0b">${p.final_abstain}</div><div class="tally-label">Abstain</div></div>
</div>
${totalFinal > 0 ? `<div class="tally-bar" style="margin:8px 0">
<div class="tally-bar-yes" style="width:${(p.final_yes/totalFinal)*100}%"></div>
<div class="tally-bar-no" style="width:${(p.final_no/totalFinal)*100}%"></div>
<div class="tally-bar-abstain" style="width:${(p.final_abstain/totalFinal)*100}%"></div>
</div>` : ""}
<div class="vote-row">
<button class="vote-btn yes" data-final-vote="YES">Vote Yes</button>
<button class="vote-btn no" data-final-vote="NO">Vote No</button>
<button class="vote-btn abstain" data-final-vote="ABSTAIN">Abstain</button>
</div>
</div>
<div class="tally">
<div class="tally-item"><div class="tally-value" style="color:#22c55e">${p.final_yes}</div><div class="tally-label">Yes</div></div>
<div class="tally-item"><div class="tally-value" style="color:#ef4444">${p.final_no}</div><div class="tally-label">No</div></div>
<div class="tally-item"><div class="tally-value" style="color:#f59e0b">${p.final_abstain}</div><div class="tally-label">Abstain</div></div>
</div>
<div class="vote-controls">
<button class="vote-btn yes" data-final-vote="YES">Vote Yes</button>
<button class="vote-btn no" data-final-vote="NO">Vote No</button>
<button class="vote-btn abstain" data-final-vote="ABSTAIN">Abstain</button>
</div>
${p.voting_ends_at ? `<div style="font-size:12px;color:#666;margin-top:8px">Voting ends: ${new Date(p.voting_ends_at).toLocaleDateString()}</div>` : ""}
` : ""}
${p.status === "PASSED" || p.status === "FAILED" ? `
<div style="margin-bottom:8px;font-size:13px;color:#888">
Final result: <strong style="color:${this.getStatusColor(p.status)}">${p.status}</strong>
(conviction score was ${Math.round(p.score)})
</div>
<div class="tally">
<div class="tally-item"><div class="tally-value" style="color:#22c55e">${p.final_yes}</div><div class="tally-label">Yes</div></div>
<div class="tally-item"><div class="tally-value" style="color:#ef4444">${p.final_no}</div><div class="tally-label">No</div></div>
<div class="tally-item"><div class="tally-value" style="color:#f59e0b">${p.final_abstain}</div><div class="tally-label">Abstain</div></div>
<div class="detail-section">
<div class="detail-section-title">Result: ${p.status}</div>
<div class="tally">
<div class="tally-item"><div class="tally-value" style="color:#22c55e">${p.final_yes}</div><div class="tally-label">Yes</div></div>
<div class="tally-item"><div class="tally-value" style="color:#ef4444">${p.final_no}</div><div class="tally-label">No</div></div>
<div class="tally-item"><div class="tally-value" style="color:#f59e0b">${p.final_abstain}</div><div class="tally-label">Abstain</div></div>
</div>
${totalFinal > 0 ? `<div class="tally-bar">
<div class="tally-bar-yes" style="width:${(p.final_yes/totalFinal)*100}%"></div>
<div class="tally-bar-no" style="width:${(p.final_no/totalFinal)*100}%"></div>
<div class="tally-bar-abstain" style="width:${(p.final_abstain/totalFinal)*100}%"></div>
</div>` : ""}
<div style="font-size:13px;color:#475569;margin-top:8px">
Conviction score was <strong style="color:${this.getStatusColor(p.status)}">${Math.round(p.score)}</strong>
</div>
</div>
` : ""}
</div>
@ -515,17 +758,15 @@ class FolkVoteDashboard extends HTMLElement {
const slug = (el as HTMLElement).dataset.space!;
this.selectedSpace = this.spaces.find((s) => s.slug === slug) || null;
this.view = "proposals";
if (this.space === "demo") {
this.render();
} else {
this.loadProposals(slug);
}
if (this.space === "demo") this.render();
else this.loadProposals(slug);
});
});
// Proposal cards
// Proposal title click → detail view
this.shadow.querySelectorAll("[data-proposal]").forEach((el) => {
el.addEventListener("click", () => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const id = (el as HTMLElement).dataset.proposal!;
this.view = "proposal";
if (this.space === "demo") {
@ -547,12 +788,13 @@ class FolkVoteDashboard extends HTMLElement {
});
});
// Conviction vote buttons
// Inline conviction vote buttons (on proposal cards)
this.shadow.querySelectorAll("[data-vote-weight]").forEach((el) => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const weight = parseInt((el as HTMLElement).dataset.voteWeight!);
if (this.selectedProposal) this.castVote(this.selectedProposal.id, weight);
const id = (el as HTMLElement).dataset.voteId || this.selectedProposal?.id;
if (id) this.castVote(id, weight);
});
});
@ -561,9 +803,32 @@ class FolkVoteDashboard extends HTMLElement {
el.addEventListener("click", (e) => {
e.stopPropagation();
const vote = (el as HTMLElement).dataset.finalVote!;
if (this.selectedProposal) this.castFinalVote(this.selectedProposal.id, vote);
const id = (el as HTMLElement).dataset.voteId || this.selectedProposal?.id;
if (id) this.castFinalVote(id, vote);
});
});
// Toggle create form
this.shadow.querySelector("[data-toggle-create]")?.addEventListener("click", () => {
this.showCreateForm = !this.showCreateForm;
this.render();
});
// Cancel create
this.shadow.querySelector("[data-cancel-create]")?.addEventListener("click", () => {
this.showCreateForm = false;
this.render();
});
// Submit create
this.shadow.querySelector("[data-submit-create]")?.addEventListener("click", () => {
const titleInput = this.shadow.getElementById("create-title") as HTMLInputElement;
const descInput = this.shadow.getElementById("create-desc") as HTMLTextAreaElement;
const title = titleInput?.value?.trim();
const desc = descInput?.value?.trim();
if (!title) return;
this.createProposal(title, desc || "");
});
}
private esc(s: string): string {

View File

@ -4,3 +4,198 @@ folk-vote-dashboard {
min-height: 400px;
padding: 20px;
}
/* ── Demo page layout ── */
.rd-page {
max-width: 720px;
margin: 0 auto;
padding: 2rem 1rem 4rem;
font-family: system-ui, -apple-system, sans-serif;
color: #e2e8f0;
}
.rd-hero {
text-align: center;
margin-bottom: 2rem;
}
.rd-hero h1 {
font-size: 2rem;
font-weight: 700;
margin: 0 0 0.5rem;
background: linear-gradient(to right, #818cf8, #c084fc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.rd-hero p {
color: #94a3b8;
font-size: 1rem;
margin: 0;
}
.rd-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
/* ── Connection badge ── */
.rd-status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.rd-status::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
}
.rd-status--connected {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
border: 1px solid rgba(34, 197, 94, 0.25);
}
.rd-status--connected::before { background: #22c55e; }
.rd-status--disconnected {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.25);
}
.rd-status--disconnected::before { background: #ef4444; }
/* ── Buttons ── */
.rd-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.4rem 1rem;
border-radius: 8px;
border: 1px solid #475569;
background: #1e293b;
color: #e2e8f0;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.rd-btn:hover:not(:disabled) { border-color: #818cf8; color: #818cf8; }
.rd-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.rd-btn--primary {
background: linear-gradient(to right, #818cf8, #6366f1);
border-color: transparent;
color: white;
}
.rd-btn--primary:hover:not(:disabled) { opacity: 0.9; color: white; }
.rd-btn--ghost {
background: transparent;
border: 1px solid #334155;
color: #94a3b8;
padding: 0;
border-radius: 6px;
font-weight: 700;
font-size: 1rem;
}
.rd-btn--ghost:hover:not(:disabled) { border-color: #f97316; color: #fb923c; }
/* ── Poll cards ── */
.rd-card {
background: #0f172a;
border: 1px solid #1e293b;
border-radius: 12px;
margin-bottom: 1rem;
overflow: hidden;
transition: border-color 0.2s;
}
.rd-card:hover { border-color: rgba(249, 115, 22, 0.35); }
.rd-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1rem 1.25rem 0.5rem;
gap: 0.75rem;
}
.rd-card-title {
font-size: 1rem;
font-weight: 600;
color: #e2e8f0;
line-height: 1.4;
}
.rd-icon { margin-right: 0.35rem; }
.rd-card-body {
padding: 0.5rem 1.25rem 1.25rem;
}
/* ── Badges ── */
.rd-badge {
display: inline-flex;
padding: 0.15rem 0.5rem;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
flex-shrink: 0;
white-space: nowrap;
}
.rd-badge--orange { background: rgba(249, 115, 22, 0.15); color: #fb923c; }
.rd-badge--green { background: rgba(34, 197, 94, 0.15); color: #22c55e; }
.rd-badge--red { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
.rd-badge--blue { background: rgba(59, 130, 246, 0.15); color: #60a5fa; }
.rd-badge--slate { background: rgba(148, 163, 184, 0.15); color: #94a3b8; }
/* ── Loading / empty ── */
.rd-loading {
text-align: center;
padding: 3rem 1rem;
color: #64748b;
}
.rd-loading-spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(129, 140, 248, 0.2);
border-top-color: #818cf8;
border-radius: 50%;
animation: rd-spin 0.8s linear infinite;
margin: 0 auto 1rem;
}
@keyframes rd-spin { to { transform: rotate(360deg); } }
.rd-empty {
text-align: center;
padding: 3rem 1rem;
color: #475569;
font-size: 0.9rem;
}
.rd-empty-icon { font-size: 2rem; margin-bottom: 0.5rem; }
/* ── Footer link ── */
.rd-footer {
text-align: center;
margin-top: 2.5rem;
padding-top: 1.5rem;
border-top: 1px solid #1e293b;
}
.rd-footer a {
color: #64748b;
text-decoration: none;
font-size: 0.85rem;
}
.rd-footer a:hover { color: #818cf8; }
/* ── 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; }
}

View File

@ -18,7 +18,7 @@ export function renderLanding(): string {
then advance to final voting.
</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rvote" class="rl-cta-primary" id="ml-primary"
<a href="/rvote/demo" class="rl-cta-primary" id="ml-primary"
style="background:linear-gradient(to right,#818cf8,#6366f1);color:white">
<span style="display:inline-flex;align-items:center;gap:0.5rem">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5,3 19,12 5,21"/></svg>
@ -101,7 +101,7 @@ export function renderLanding(): string {
<p style="color:#94a3b8;max-width:640px;margin:0 auto 2rem">
Vote on live polls synced across the r* ecosystem. Changes appear in real-time for everyone.
</p>
<a href="https://demo.rspace.online/rvote" class="rl-cta-primary"
<a href="/rvote/demo" class="rl-cta-primary"
style="background:linear-gradient(to right,#818cf8,#6366f1);color:white">
Open Interactive Demo
</a>
@ -375,7 +375,7 @@ export function renderLanding(): string {
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
</span>
</a>
<a href="https://demo.rspace.online/rvote" class="rl-cta-secondary">
<a href="/rvote/demo" class="rl-cta-secondary">
<span style="display:inline-flex;align-items:center;gap:0.5rem">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5,3 19,12 5,21"/></svg>
Interactive Demo

View File

@ -506,7 +506,51 @@ routes.post("/api/proposals/:id/final-vote", async (c) => {
return c.json({ ok: true, tally });
});
// ── Page route ──
// ── 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">
<h1>rVote Demo</h1>
<p>Vote on live proposals synced across the r* ecosystem. Changes appear in real-time 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">&larr; About rVote</a>
</div>
<div id="rd-loading" class="rd-loading">
<div class="rd-loading-spinner"></div>
Loading polls...
</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>
<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({

View File

@ -372,6 +372,31 @@ export default defineConfig({
},
});
// Build vote demo page script
await build({
configFile: false,
root: resolve(__dirname, "modules/rvote/components"),
resolve: {
alias: {
"@lib": resolve(__dirname, "./lib"),
},
},
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rvote"),
lib: {
entry: resolve(__dirname, "modules/rvote/components/vote-demo.ts"),
formats: ["es"],
fileName: () => "vote-demo.js",
},
rollupOptions: {
output: {
entryFileNames: "vote-demo.js",
},
},
},
});
// Copy vote CSS
mkdirSync(resolve(__dirname, "dist/modules/rvote"), { recursive: true });
copyFileSync(