feat(rchoices): inline CrowdSurf swipe cards with sortition

Replace CrowdSurf tab placeholder with working swipe-card interface
populated from rChoices session data (or demo fallback). Uses seeded
PRNG (mulberry32 + djb2 hash) for deterministic daily sortition per
user, preventing position bias. Right-swipe = approve (casts vote via
local-first client), left-swipe = skip. Swipe state persists in
localStorage across page reloads. Includes summary view with
session-grouped approvals and reset functionality.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 17:29:50 -07:00
parent 1811f5e7b4
commit 383441edf7
1 changed files with 404 additions and 7 deletions

View File

@ -9,6 +9,16 @@ import { TourEngine } from "../../../shared/tour-engine";
import { ChoicesLocalFirstClient } from "../local-first-client";
import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas";
// ── CrowdSurf types ──
interface CrowdSurfOption {
optionId: string;
label: string;
color: string;
sessionId: string;
sessionTitle: string;
sessionType: 'vote' | 'rank' | 'score';
}
// ── Auth helpers ──
function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null {
try {
@ -47,6 +57,16 @@ class FolkChoicesDashboard extends HTMLElement {
private activeSessionId: string | null = null;
private sessionVotes: Map<string, ChoiceVote[]> = new Map();
/* CrowdSurf inline state */
private csOptions: CrowdSurfOption[] = [];
private csCurrentIndex = 0;
private csSwipedMap: Map<string, 'right' | 'left'> = new Map();
private csIsDragging = false;
private csStartX = 0;
private csCurrentX = 0;
private csIsAnimating = false;
private _csTransitionTimer: number | null = null;
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
@ -83,6 +103,10 @@ class FolkChoicesDashboard extends HTMLElement {
clearInterval(this.simTimer);
this.simTimer = null;
}
if (this._csTransitionTimer !== null) {
clearTimeout(this._csTransitionTimer);
this._csTransitionTimer = null;
}
this._lfcUnsub?.();
this._lfcUnsub = null;
this.lfClient?.disconnect();
@ -573,6 +597,30 @@ class FolkChoicesDashboard extends HTMLElement {
.vote-reset:hover { border-color: var(--rs-error); color: #fca5a5; }
.vote-status { text-align: center; margin-bottom: 1rem; font-size: 0.8rem; color: var(--rs-text-muted); }
/* CrowdSurf inline */
.cs-inline { max-width: 420px; margin-inline: auto; }
.cs-progress-header { display: flex; justify-content: space-between; margin-bottom: 0.75rem; }
.cs-card-stack { display: flex; flex-direction: column; align-items: center; min-height: 240px; justify-content: center; }
.cs-card { position: relative; width: 100%; background: linear-gradient(135deg, var(--rs-bg-surface) 0%, var(--rs-bg-surface-raised, var(--rs-bg-surface)) 100%); border: 1px solid var(--rs-border); border-radius: 16px; padding: 1.5rem; cursor: grab; user-select: none; touch-action: pan-y; }
.cs-card:active { cursor: grabbing; }
.cs-card-body { display: flex; flex-direction: column; gap: 0.75rem; }
.cs-type-badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; width: fit-content; }
.cs-card-session { font-size: 0.85rem; color: var(--rs-text-secondary); }
.cs-card-option { font-size: 1.3rem; font-weight: 700; color: var(--rs-text-primary); display: flex; align-items: center; gap: 10px; }
.cs-color-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.cs-swipe-indicator { position: absolute; top: 50%; transform: translateY(-50%); font-size: 1.1rem; font-weight: 700; padding: 6px 14px; border-radius: 8px; opacity: 0; transition: opacity 0.15s; pointer-events: none; z-index: 2; }
.cs-swipe-left { left: 12px; color: #ef4444; background: rgba(239,68,68,0.15); }
.cs-swipe-right { right: 12px; color: #22c55e; background: rgba(34,197,94,0.15); }
.cs-swipe-indicator.show { opacity: 1; }
.cs-swipe-buttons { display: flex; justify-content: center; gap: 2rem; margin-top: 1.25rem; }
.cs-btn-skip, .cs-btn-approve { width: 52px; height: 52px; border-radius: 50%; border: 2px solid; font-size: 1.3rem; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; background: var(--rs-bg-surface); font-family: inherit; }
.cs-btn-skip { border-color: #ef4444; color: #ef4444; }
.cs-btn-skip:hover { background: rgba(239,68,68,0.15); }
.cs-btn-approve { border-color: #22c55e; color: #22c55e; }
.cs-btn-approve:hover { background: rgba(34,197,94,0.15); }
.cs-btn-reset { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 0.85rem; font-family: inherit; transition: all 0.15s; margin-top: 0.75rem; }
.cs-btn-reset:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); }
@media (max-width: 768px) {
.grid { grid-template-columns: 1fr; }
}
@ -588,6 +636,9 @@ class FolkChoicesDashboard extends HTMLElement {
.rank-name { font-size: 0.875rem; }
.vote-option { padding: 0.625rem 0.75rem; }
.spider-svg { max-width: 300px; }
.cs-card { padding: 1.25rem; border-radius: 12px; }
.cs-card-option { font-size: 1.1rem; }
.cs-btn-skip, .cs-btn-approve { width: 46px; height: 46px; font-size: 1.1rem; }
}
</style>
@ -731,17 +782,324 @@ class FolkChoicesDashboard extends HTMLElement {
</div>`;
}
/* -- CrowdSurf helpers -- */
private static mulberry32(seed: number): () => number {
let s = seed | 0;
return () => {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
private static hashString(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
}
return hash >>> 0;
}
private buildCrowdSurfOptions() {
const myDid = getMyDid() || 'anon';
const lsKey = `cs_swiped:${this.space}:${myDid}`;
// Restore persisted swipes
try {
const saved = localStorage.getItem(lsKey);
if (saved) {
const entries: [string, 'right' | 'left'][] = JSON.parse(saved);
this.csSwipedMap = new Map(entries);
}
} catch { /* ignore */ }
// Build pool from live sessions or demo data
let pool: CrowdSurfOption[] = [];
// Try live sessions first
const openSessions = this.sessions.filter(s => !s.closed);
for (const session of openSessions) {
const sType: 'vote' | 'rank' | 'score' = session.type === 'rank' ? 'rank' : session.type === 'score' ? 'score' : 'vote';
for (const opt of session.options) {
pool.push({
optionId: opt.id,
label: opt.label,
color: opt.color,
sessionId: session.id,
sessionTitle: session.title,
sessionType: sType,
});
}
}
// Demo mode fallback
if (this.space === 'demo' && pool.length === 0) {
for (const opt of this.voteOptions) {
pool.push({
optionId: opt.id,
label: opt.name,
color: opt.color,
sessionId: 'demo-vote',
sessionTitle: 'Movie Night',
sessionType: 'vote',
});
}
for (const item of this.rankItems) {
pool.push({
optionId: String(item.id),
label: item.name,
color: '#f59e0b',
sessionId: 'demo-rank',
sessionTitle: 'Lunch Spot',
sessionType: 'rank',
});
}
}
// Filter already swiped
pool = pool.filter(o => !this.csSwipedMap.has(`${o.sessionId}:${o.optionId}`));
// Seeded shuffle (Fisher-Yates)
const today = new Date().toISOString().slice(0, 10);
const seed = FolkChoicesDashboard.hashString(`${myDid}:${this.space}:${today}`);
const rng = FolkChoicesDashboard.mulberry32(seed);
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
this.csOptions = pool.slice(0, Math.min(10, pool.length));
this.csCurrentIndex = 0;
}
private renderCrowdSurf(): string {
return `<div style="text-align:center;padding:2rem 1rem;">
<div style="font-size:3rem;margin-bottom:0.75rem;">🏄</div>
<h3 style="color:var(--rs-text-primary);margin:0 0 0.5rem;">CrowdSurf</h3>
<p style="color:var(--rs-text-secondary);margin:0 0 1.5rem;max-width:360px;margin-inline:auto;">
Swipe-based community coordination. Propose activities, set commitment thresholds, and watch them trigger when enough people join.
</p>
<a href="/${this.space}/crowdsurf" style="display:inline-block;padding:0.6rem 1.5rem;background:var(--rs-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;font-size:0.9rem;">Open CrowdSurf</a>
// Build options if empty or all consumed
if (this.csOptions.length === 0 || this.csCurrentIndex >= this.csOptions.length) {
this.buildCrowdSurfOptions();
}
// No options at all
if (this.csOptions.length === 0) {
return `<div class="cs-inline">
<div style="text-align:center;padding:2rem 0;">
<div style="font-size:2.5rem;margin-bottom:0.75rem;">🏄</div>
<p style="color:var(--rs-text-secondary);margin:0 0 1rem;">No open polls to surf yet.</p>
<p style="color:var(--rs-text-muted);font-size:0.8rem;margin:0 0 1rem;">Create a poll in the Voting tab, then come back to swipe!</p>
${this.csSwipedMap.size > 0 ? `<button class="cs-btn-reset" data-cs-action="reset">Start Over</button>` : ''}
</div>
</div>`;
}
// All swiped — show summary
if (this.csCurrentIndex >= this.csOptions.length) {
return this.renderCrowdSurfSummary();
}
// Active card
const opt = this.csOptions[this.csCurrentIndex];
const approved = Array.from(this.csSwipedMap.values()).filter(v => v === 'right').length;
const typeBadgeColors: Record<string, string> = { vote: '#3b82f6', rank: '#f59e0b', score: '#10b981' };
const badgeColor = typeBadgeColors[opt.sessionType] || '#3b82f6';
return `<div class="cs-inline">
<div class="cs-progress-header">
<span style="color:var(--rs-text-muted);font-size:0.8rem;">${this.csCurrentIndex + 1} of ${this.csOptions.length}</span>
<span style="color:var(--rs-text-muted);font-size:0.8rem;">${approved} approved</span>
</div>
<div class="cs-card-stack">
<div class="cs-card" id="cs-inline-card">
<div class="cs-swipe-indicator cs-swipe-left">&#10007; Skip</div>
<div class="cs-swipe-indicator cs-swipe-right">&#10003; Approve</div>
<div class="cs-card-body">
<div class="cs-type-badge" style="background:${badgeColor}20;color:${badgeColor}">${opt.sessionType}</div>
<div class="cs-card-session">${this.esc(opt.sessionTitle)}</div>
<div class="cs-card-option">
<span class="cs-color-dot" style="background:${opt.color}"></span>
${this.esc(opt.label)}
</div>
</div>
</div>
</div>
<div class="cs-swipe-buttons">
<button class="cs-btn-skip" data-cs-action="skip" title="Skip">&#10007;</button>
<button class="cs-btn-approve" data-cs-action="approve" title="Approve">&#10003;</button>
</div>
</div>`;
}
private renderCrowdSurfSummary(): string {
const approved: CrowdSurfOption[] = [];
this.csSwipedMap.forEach((dir, key) => {
if (dir !== 'right') return;
const [sessionId, optionId] = key.split(':');
const opt = this.csOptions.find(o => o.sessionId === sessionId && o.optionId === optionId);
if (opt) approved.push(opt);
});
// Group by session
const grouped = new Map<string, CrowdSurfOption[]>();
for (const opt of approved) {
const list = grouped.get(opt.sessionId) || [];
list.push(opt);
grouped.set(opt.sessionId, list);
}
let groupHtml = '';
grouped.forEach((opts) => {
const title = opts[0].sessionTitle;
const items = opts.map(o =>
`<div style="display:flex;align-items:center;gap:6px;padding:4px 0;">
<span class="cs-color-dot" style="background:${o.color}"></span>
<span style="color:var(--rs-text-primary);font-size:0.9rem;">${this.esc(o.label)}</span>
</div>`
).join('');
groupHtml += `<div style="margin-bottom:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--rs-text-muted);margin-bottom:4px;">${this.esc(title)}</div>
${items}
</div>`;
});
return `<div class="cs-inline">
<div style="text-align:center;padding:1.5rem 0;">
<div style="font-size:2rem;margin-bottom:0.5rem;">&#10003;</div>
<h3 style="color:var(--rs-text-primary);margin:0 0 0.5rem;font-size:1.1rem;">All done!</h3>
<p style="color:var(--rs-text-secondary);margin:0 0 1.25rem;font-size:0.85rem;">
You approved ${approved.length} of ${this.csSwipedMap.size} options
</p>
${groupHtml || `<p style="color:var(--rs-text-muted);font-size:0.85rem;">No approvals this round.</p>`}
<button class="cs-btn-reset" data-cs-action="reset">Start Over</button>
</div>
</div>`;
}
private setupCrowdSurfSwipe() {
const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null;
if (!card) return;
// Clear any pending transition timer from previous card
if (this._csTransitionTimer !== null) {
clearTimeout(this._csTransitionTimer);
this._csTransitionTimer = null;
}
const handleStart = (clientX: number) => {
if (this.csIsAnimating) return;
// Clear any lingering transition
if (this._csTransitionTimer !== null) {
clearTimeout(this._csTransitionTimer);
this._csTransitionTimer = null;
}
card.style.transition = '';
this.csStartX = clientX;
this.csCurrentX = clientX;
this.csIsDragging = true;
};
const handleMove = (clientX: number) => {
if (!this.csIsDragging || this.csIsAnimating) return;
this.csCurrentX = clientX;
const diffX = this.csCurrentX - this.csStartX;
const rotation = diffX * 0.1;
card.style.transform = `translateX(${diffX}px) rotate(${rotation}deg)`;
const leftInd = card.querySelector('.cs-swipe-left') as HTMLElement;
const rightInd = card.querySelector('.cs-swipe-right') as HTMLElement;
if (diffX < -50) {
leftInd?.classList.add('show');
rightInd?.classList.remove('show');
} else if (diffX > 50) {
rightInd?.classList.add('show');
leftInd?.classList.remove('show');
} else {
leftInd?.classList.remove('show');
rightInd?.classList.remove('show');
}
};
const handleEnd = () => {
if (!this.csIsDragging || this.csIsAnimating) return;
this.csIsDragging = false;
const diffX = this.csCurrentX - this.csStartX;
card.querySelector('.cs-swipe-left')?.classList.remove('show');
card.querySelector('.cs-swipe-right')?.classList.remove('show');
if (Math.abs(diffX) > 100) {
const direction = diffX > 0 ? 1 : -1;
this.csIsAnimating = true;
card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out';
card.style.transform = `translateX(${direction * 500}px) rotate(${direction * 30}deg)`;
card.style.opacity = '0';
this._csTransitionTimer = window.setTimeout(() => {
card.style.transform = '';
card.style.opacity = '';
card.style.transition = '';
this._csTransitionTimer = null;
this.handleCrowdSurfSwipe(diffX > 0 ? 'right' : 'left');
}, 300);
} else {
card.style.transition = 'transform 0.2s ease-out';
card.style.transform = '';
this._csTransitionTimer = window.setTimeout(() => {
card.style.transition = '';
this._csTransitionTimer = null;
}, 200);
}
};
card.addEventListener('pointerdown', (e: PointerEvent) => {
if (e.button !== 0) return;
card.setPointerCapture(e.pointerId);
card.style.touchAction = 'none';
handleStart(e.clientX);
});
card.addEventListener('pointermove', (e: PointerEvent) => {
e.preventDefault();
handleMove(e.clientX);
});
card.addEventListener('pointerup', () => handleEnd());
card.addEventListener('pointercancel', () => {
this.csIsDragging = false;
card.style.transform = '';
card.style.transition = '';
card.style.opacity = '';
card.style.touchAction = '';
});
}
private handleCrowdSurfSwipe(direction: 'right' | 'left') {
this.csIsAnimating = false;
if (this.csCurrentIndex >= this.csOptions.length) return;
const opt = this.csOptions[this.csCurrentIndex];
const swipeKey = `${opt.sessionId}:${opt.optionId}`;
this.csSwipedMap.set(swipeKey, direction);
// Persist to localStorage
const myDid = getMyDid() || 'anon';
const lsKey = `cs_swiped:${this.space}:${myDid}`;
try {
localStorage.setItem(lsKey, JSON.stringify(Array.from(this.csSwipedMap.entries())));
} catch { /* quota */ }
// Cast vote on right swipe (live mode)
if (direction === 'right' && this.lfClient && opt.sessionId !== 'demo-vote' && opt.sessionId !== 'demo-rank') {
const did = getMyDid();
if (did) {
const existing = this.lfClient.getMyVote(opt.sessionId, did);
const newChoices = { ...(existing?.choices || {}), [opt.optionId]: 1 };
this.lfClient.castVote(opt.sessionId, did, newChoices);
}
}
this.csCurrentIndex++;
this.renderDemo();
this.bindDemoEvents();
}
/* -- Demo event binding -- */
private bindDemoEvents() {
@ -861,6 +1219,45 @@ class FolkChoicesDashboard extends HTMLElement {
this.renderDemo();
});
}
// CrowdSurf swipe + buttons
this.setupCrowdSurfSwipe();
this.shadow.querySelector('[data-cs-action="skip"]')?.addEventListener('click', () => {
if (this.csIsAnimating) return;
const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null;
if (card) {
this.csIsAnimating = true;
card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out';
card.style.transform = 'translateX(-500px) rotate(-30deg)';
card.style.opacity = '0';
setTimeout(() => this.handleCrowdSurfSwipe('left'), 300);
} else {
this.handleCrowdSurfSwipe('left');
}
});
this.shadow.querySelector('[data-cs-action="approve"]')?.addEventListener('click', () => {
if (this.csIsAnimating) return;
const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null;
if (card) {
this.csIsAnimating = true;
card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out';
card.style.transform = 'translateX(500px) rotate(30deg)';
card.style.opacity = '0';
setTimeout(() => this.handleCrowdSurfSwipe('right'), 300);
} else {
this.handleCrowdSurfSwipe('right');
}
});
this.shadow.querySelector('[data-cs-action="reset"]')?.addEventListener('click', () => {
const myDid = getMyDid() || 'anon';
const lsKey = `cs_swiped:${this.space}:${myDid}`;
localStorage.removeItem(lsKey);
this.csSwipedMap.clear();
this.csOptions = [];
this.csCurrentIndex = 0;
this.renderDemo();
this.bindDemoEvents();
});
}
private esc(s: string): string {