Compare commits

..

1 Commits

Author SHA1 Message Date
Andrew
69464a16b6 Initial release of P2P Governance prototype 2026-03-15 12:19:02 +01:00
14 changed files with 224 additions and 2324 deletions

2
.gitignore vendored
View File

@@ -1,2 +0,0 @@
dist
node_modules

View File

@@ -1,44 +1,24 @@
# P2P Poll App # 🗳️ Simple P2P Poll & Chat
A peer-to-peer polling application where users create and vote on polls without any central server. All data syncs directly between browsers using WebRTC and CRDTs. This is a minimal prototype of a decentralized governance tool built for the **Demcode Evocracy** experiment. It allows for basic deliberation and voting without a central database or server-side logic.
## Features ## What it does
- **P2P Connectivity:** Uses WebRTC (via PeerJS) to link browsers directly.
- **Mesh-capable:** Allows multiple connections to sync the same poll state.
- **Live Sync:** Changes to the poll topic, options, or votes are broadcast to all connected peers.
- **Chat:** A simple sidebar to discuss the poll in real-time.
- **Real-time P2P sync** — poll options and votes sync instantly across all connected peers via WebRTC ## How to use it
- **Collaborative poll title** — editable title that syncs between all participants 1. Open the page: [https://andrewlehr.github.io/evocracy-p2p-poll/]
- **One vote per user** — each peer gets a stable ID, enforcing one vote per person per option 2. Set your **Nickname** and click "Set".
- **Vote/Unvote toggle** — change your mind anytime 3. Share your nickname with a friend.
- **Connection status** — see when you're connected and how many peers are in the room 4. One person enters the other's nickname in the **Connect** box and clicks "Add Peer".
- **Shareable polls** — share via URL with `?room=your-poll-name` 5. Once connected, you can set a topic, add options, and vote.
- **No backend required** — runs entirely in the browser
## Tech Stack ## Technical Notes
- **Data Persistence:** There is no database. If everyone closes their browser, the data is lost.
- **Networking:** It uses a public signaling server to find peers, but the actual data (votes/chat) stays between the browsers.
- **Conflict Handling:** Uses a simple "Last Write Wins" broadcast to keep peers in sync.
- [Yjs](https://yjs.dev/) — CRDT library for conflict-free shared state ## Transparency
- [y-webrtc](https://github.com/yjs/y-webrtc) — WebRTC provider for peer-to-peer connections This code was generated with the assistance of **Gemini (Google AI)** to quickly prototype the WebRTC networking logic. The goal was to create a clean, readable foundation that is easy for others to merge or modify during the next phase of the experiment.
- [Vite](https://vitejs.dev/) — Development server and build tool
- Vanilla JavaScript — no framework dependencies
## Getting Started
```bash
npm install
npm run dev
```
Open `http://localhost:5173/?room=my-poll` in multiple browser tabs to test.
To test across devices on the same network:
```bash
npm run dev -- --host
```
Then open the URL shown in the terminal on other devices.
## How It Works
1. Each browser tab creates a Yjs document and connects to other peers via WebRTC
2. Poll options and votes are stored in Yjs shared data types (Y.Map)
3. Changes propagate automatically to all connected peers using CRDTs
4. A public signaling server handles peer discovery; all poll data flows directly between browsers

View File

@@ -1,13 +1,212 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Polly — P2P Polls</title> <title>Mesh Governance Tool</title>
<link rel="stylesheet" href="/src/style.css"> <script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
<style>
:root { --primary: #007AFF; --bg: #f2f2f7; --success: #34c759; --secondary: #8e8e93; }
* { box-sizing: border-box; }
body { font-family: -apple-system, sans-serif; margin: 0; background: var(--bg); height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
/* Header & Peer List */
.header { background: #fff; padding: 12px; border-bottom: 1px solid #d1d1d6; flex-shrink: 0; }
.setup-row { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; }
.peer-list-container { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; }
.peer-list-container::-webkit-scrollbar { display: none; }
.peer-chip { background: #e9ecef; padding: 4px 10px; border-radius: 12px; font-size: 11px; white-space: nowrap; border: 1px solid #dee2e6; display: flex; align-items: center; gap: 5px; }
.dot { width: 6px; height: 6px; background: var(--success); border-radius: 50%; }
/* Main Layout */
.main-container { display: flex; flex: 1; overflow: hidden; }
@media (max-width: 768px) { .main-container { flex-direction: column; } }
.column { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #fff; border-right: 1px solid #d1d1d6; }
.scroll-area { flex: 1; overflow-y: auto; padding: 16px; -webkit-overflow-scrolling: touch; }
/* Components */
.card { background: #f9f9f9; border-radius: 10px; padding: 12px; margin-bottom: 15px; border: 1px solid #e5e5ea; }
input { border: 1px solid #c6c6c8; padding: 10px; border-radius: 8px; font-size: 16px; width: 100%; outline: none; }
button { background: var(--primary); color: white; border: none; padding: 10px; border-radius: 8px; font-weight: 600; cursor: pointer; }
/* Poll UI */
.poll-question { font-size: 20px; font-weight: 700; margin-bottom: 15px; color: var(--primary); }
.option-item { display: flex; justify-content: space-between; align-items: center; background: white; padding: 12px; border-radius: 10px; margin-bottom: 8px; border: 1px solid #e5e5ea; }
/* Chat UI */
#chat-messages { display: flex; flex-direction: column; gap: 8px; }
.msg { padding: 8px 12px; border-radius: 14px; max-width: 85%; font-size: 14px; }
.msg.sent { background: var(--primary); color: white; align-self: flex-end; }
.msg.received { background: #e5e5ea; color: black; align-self: flex-start; }
.msg-sender { font-size: 10px; font-weight: bold; opacity: 0.7; display: block; }
.chat-input-bar { padding: 12px; background: #fff; border-top: 1px solid #d1d1d6; display: flex; gap: 8px; padding-bottom: calc(12px + env(safe-area-inset-bottom)); }
</style>
</head> </head>
<body> <body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script> <div class="header">
<div class="setup-row">
<span style="font-size: 13px;">Me: <strong id="my-id-display">...</strong></span>
<div id="peer-list" class="peer-list-container">
</div>
</div>
<div class="setup-row">
<input type="text" id="nickname-input" placeholder="My Name" style="flex: 0.5;">
<button onclick="joinAs(document.getElementById('nickname-input').value)" style="background: var(--secondary); padding: 8px 12px;">Set</button>
<input type="text" id="peer-id-input" placeholder="Friend's Name" style="flex: 1;">
<button onclick="connectToPeer()" style="padding: 8px 12px;">Connect</button>
</div>
</div>
<div class="main-container">
<div class="column">
<div class="scroll-area">
<div class="card">
<input type="text" id="poll-title-input" placeholder="Question/Topic?">
<button onclick="updateTitle()" style="width:100%; margin-top:8px;">Set Topic</button>
</div>
<div id="active-question" class="poll-question">Waiting for Topic...</div>
<div style="display:flex; gap:8px; margin-bottom:15px;">
<input type="text" id="new-option-text" placeholder="Add option...">
<button onclick="addOption()">Add</button>
</div>
<div id="poll-container"></div>
</div>
</div>
<div class="column">
<div class="scroll-area" id="chat-scroll">
<div id="chat-messages"></div>
</div>
<div class="chat-input-bar">
<input type="text" id="chat-input" placeholder="Discuss...">
<button onclick="sendMessage()">Send</button>
</div>
</div>
</div>
<script>
let pollData = { question: "Open Discussion", options: [] };
let myName = "";
let connections = [];
let peer = null;
function joinAs(id) {
if (peer) peer.destroy();
peer = new Peer(id || undefined);
peer.on('open', newId => {
myName = newId;
document.getElementById('my-id-display').innerText = newId;
});
peer.on('connection', conn => setupConnection(conn));
}
function connectToPeer() {
const target = document.getElementById('peer-id-input').value;
if (target && target !== myName) {
setupConnection(peer.connect(target));
}
document.getElementById('peer-id-input').value = "";
}
function setupConnection(conn) {
conn.on('open', () => {
// Prevent duplicate connections
if (!connections.find(c => c.peer === conn.peer)) {
connections.push(conn);
updatePeerUI();
broadcast({ type: 'SYNC', payload: pollData });
}
});
conn.on('data', data => {
if (data.type === 'SYNC') {
pollData = data.payload;
renderPoll();
} else if (data.type === 'CHAT') {
appendMessage(data.sender, data.payload, 'received');
}
});
conn.on('close', () => {
connections = connections.filter(c => c.peer !== conn.peer);
updatePeerUI();
});
}
function broadcast(data) {
connections.forEach(c => { if (c.open) c.send(data); });
}
function updatePeerUI() {
const list = document.getElementById('peer-list');
if (connections.length === 0) {
list.innerHTML = '<span style="font-size:11px; color:#999;">No peers connected</span>';
return;
}
list.innerHTML = connections.map(c => `
<div class="peer-chip"><div class="dot"></div>${c.peer}</div>
`).join('');
}
// --- APP LOGIC ---
function updateTitle() {
const val = document.getElementById('poll-title-input').value;
if (!val) return;
pollData.question = val;
renderPoll();
broadcast({ type: 'SYNC', payload: pollData });
}
function addOption() {
const txt = document.getElementById('new-option-text').value;
if (!txt) return;
pollData.options.push({ text: txt, votes: 0 });
document.getElementById('new-option-text').value = "";
renderPoll();
broadcast({ type: 'SYNC', payload: pollData });
}
function vote(i) {
pollData.options[i].votes++;
renderPoll();
broadcast({ type: 'SYNC', payload: pollData });
}
function renderPoll() {
document.getElementById('active-question').innerText = pollData.question;
const container = document.getElementById('poll-container');
container.innerHTML = pollData.options.map((opt, i) => `
<div class="option-item">
<span>${opt.text}</span>
<div style="display:flex; align-items:center;">
<span style="font-weight:bold; color:var(--primary); margin-right:12px; font-size:18px;">${opt.votes}</span>
<button onclick="vote(${i})" style="background:var(--success); padding:5px 12px;">Vote</button>
</div>
</div>
`).join('');
}
function sendMessage() {
const input = document.getElementById('chat-input');
if (!input.value) return;
broadcast({ type: 'CHAT', sender: myName, payload: input.value });
appendMessage('You', input.value, 'sent');
input.value = "";
}
function appendMessage(sender, text, side) {
const div = document.createElement('div');
div.className = `msg ${side}`;
div.innerHTML = `<span class="msg-sender">${sender}</span>${text}`;
document.getElementById('chat-messages').appendChild(div);
const scroll = document.getElementById('chat-scroll');
scroll.scrollTop = scroll.scrollHeight;
}
document.getElementById('chat-input').addEventListener('keypress', (e) => { if (e.key === 'Enter') sendMessage(); });
joinAs(null);
</script>
</body> </body>
</html> </html>

1437
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +0,0 @@
{
"name": "p2p-poll",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"yjs": "^13.6.0",
"y-webrtc": "^10.3.0"
},
"devDependencies": {
"vite": "^6.0.0"
}
}

View File

@@ -1,47 +0,0 @@
import { addOption } from '../utils/store.js';
export function AddOption() {
const wrapper = document.createElement('div');
wrapper.className = 'add-option-wrapper';
const input = document.createElement('input');
input.type = 'text';
input.className = 'add-option-input';
input.placeholder = 'Add an option…';
input.maxLength = 100;
input.setAttribute('aria-label', 'New poll option');
const btn = document.createElement('button');
btn.className = 'add-option-btn';
btn.setAttribute('aria-label', 'Add option');
// Plus icon
btn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
</svg>
<span>Add</span>
`;
wrapper.append(input, btn);
function submit() {
const name = input.value.trim();
if (!name) {
input.focus();
input.classList.add('shake');
input.addEventListener('animationend', () => input.classList.remove('shake'), { once: true });
return;
}
addOption(name);
input.value = '';
input.focus();
}
btn.addEventListener('click', submit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submit();
});
return wrapper;
}

View File

@@ -1,77 +0,0 @@
import { yOptions, getEntries, getTotalVotes } from '../utils/store.js';
import { PollOption } from './PollOption.js';
export function PollList() {
const wrapper = document.createElement('div');
wrapper.className = 'poll-list-wrapper';
const meta = document.createElement('div');
meta.className = 'poll-list-meta';
const list = document.createElement('div');
list.className = 'poll-list';
const empty = document.createElement('div');
empty.className = 'poll-list-empty';
empty.innerHTML = `
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="10" width="24" height="3" rx="1.5" fill="currentColor" opacity="0.15"/>
<rect x="4" y="16" width="18" height="3" rx="1.5" fill="currentColor" opacity="0.1"/>
<rect x="4" y="22" width="21" height="3" rx="1.5" fill="currentColor" opacity="0.07"/>
</svg>
</div>
<p>No options yet — add the first one above.</p>
`;
wrapper.append(meta, list, empty);
function render() {
const entries = getEntries();
const total = getTotalVotes();
// Meta line
if (entries.length > 0) {
meta.textContent = `${entries.length} option${entries.length !== 1 ? 's' : ''} · ${total} vote${total !== 1 ? 's' : ''} total`;
meta.style.display = '';
} else {
meta.style.display = 'none';
}
// Empty state
empty.style.display = entries.length === 0 ? '' : 'none';
// Diff-render: reuse existing rows when possible
const existing = new Map(
[...list.querySelectorAll('.poll-option')].map((el) => [el.dataset.id, el])
);
// Remove stale rows
existing.forEach((el, id) => {
if (!entries.find((e) => e.id === id)) el.remove();
});
// Update or insert rows in sorted order
entries.forEach((entry, i) => {
const newEl = PollOption({ ...entry, totalVotes: total });
const currentEl = list.children[i];
if (!currentEl) {
list.appendChild(newEl);
} else if (currentEl.dataset.id !== entry.id) {
list.insertBefore(newEl, currentEl);
// Remove the now-displaced old element if it was this id
const old = existing.get(entry.id);
if (old && old !== currentEl) old.remove();
} else {
// Replace in-place so vote bar animation triggers
list.replaceChild(newEl, currentEl);
}
});
}
yOptions.observeDeep(() => render());
render();
return wrapper;
}

View File

@@ -1,44 +0,0 @@
import { toggleVote, deleteOption } from '../utils/store.js';
/**
* @param {{ id: string, name: string, votes: number, voted: boolean, totalVotes: number }} entry
*/
export function PollOption({ id, name, votes, voted, totalVotes }) {
const row = document.createElement('div');
row.className = `poll-option${voted ? ' poll-option--voted' : ''}`;
row.dataset.id = id;
const pct = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0;
row.innerHTML = `
<div class="poll-option__bar" style="width: ${pct}%"></div>
<div class="poll-option__content">
<span class="poll-option__name">${escapeHtml(name)}</span>
<div class="poll-option__actions">
<span class="poll-option__pct">${pct}%</span>
<span class="poll-option__count">${votes} vote${votes !== 1 ? 's' : ''}</span>
<button class="poll-option__vote-btn" aria-pressed="${voted}">
${voted ? 'Voted' : 'Vote'}
</button>
<button class="poll-option__delete-btn" aria-label="Remove option">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
</div>
`;
row.querySelector('.poll-option__vote-btn').addEventListener('click', () => toggleVote(id));
row.querySelector('.poll-option__delete-btn').addEventListener('click', () => deleteOption(id));
return row;
}
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -1,34 +0,0 @@
import { ydoc, yTitle } from '../utils/store.js';
export function PollTitle() {
const wrapper = document.createElement('div');
wrapper.className = 'poll-title-wrapper';
const input = document.createElement('input');
input.type = 'text';
input.id = 'poll-title';
input.className = 'poll-title-input';
input.placeholder = 'Untitled Poll';
input.maxLength = 120;
input.setAttribute('aria-label', 'Poll title');
input.value = yTitle.toString();
wrapper.appendChild(input);
// Sync from Yjs → input (only when not focused to avoid cursor jump)
yTitle.observe(() => {
if (document.activeElement !== input) {
input.value = yTitle.toString();
}
});
// Sync from input → Yjs
input.addEventListener('input', () => {
ydoc.transact(() => {
yTitle.delete(0, yTitle.length);
yTitle.insert(0, input.value);
});
});
return wrapper;
}

View File

@@ -1,38 +0,0 @@
import { roomName } from '../utils/store.js';
export function ShareSection() {
const url = `${window.location.origin}${window.location.pathname}?room=${encodeURIComponent(roomName)}`;
const section = document.createElement('div');
section.className = 'share-section';
section.innerHTML = `
<p class="share-label">Share this poll</p>
<div class="share-row">
<code class="share-url" title="${url}">${url}</code>
<button class="share-copy-btn">Copy link</button>
</div>
`;
const copyBtn = section.querySelector('.share-copy-btn');
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(url);
copyBtn.textContent = 'Copied!';
copyBtn.classList.add('share-copy-btn--success');
setTimeout(() => {
copyBtn.textContent = 'Copy link';
copyBtn.classList.remove('share-copy-btn--success');
}, 2000);
} catch {
// Fallback: select the text
const range = document.createRange();
range.selectNode(section.querySelector('.share-url'));
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
}
});
return section;
}

View File

@@ -1,50 +0,0 @@
import { provider } from '../utils/store.js';
export function StatusBar() {
const el = document.createElement('div');
el.className = 'status-bar';
const dot = document.createElement('span');
dot.className = 'status-dot connecting';
const statusText = document.createElement('span');
statusText.className = 'status-text';
statusText.textContent = 'Connecting';
const divider = document.createElement('span');
divider.className = 'status-divider';
divider.textContent = '·';
const peerText = document.createElement('span');
peerText.className = 'status-peers';
el.append(dot, statusText, divider, peerText);
// --- Connection state ---
let syncTimeout = setTimeout(() => {
statusText.textContent = 'Ready';
dot.className = 'status-dot ready';
}, 3000);
provider.on('synced', ({ synced }) => {
clearTimeout(syncTimeout);
dot.className = `status-dot ${synced ? 'connected' : 'connecting'}`;
statusText.textContent = synced ? 'Connected' : 'Connecting';
});
// --- Peer count ---
function updatePeerCount() {
const total = provider.awareness.getStates().size;
const others = total - 1;
peerText.textContent = others === 0
? 'Only you'
: `${others} other${others !== 1 ? 's' : ''}`;
}
provider.awareness.on('change', updatePeerCount);
updatePeerCount();
return el;
}

View File

@@ -1,41 +0,0 @@
import { StatusBar } from './components/StatusBar.js';
import { PollTitle } from './components/PollTitle.js';
import { AddOption } from './components/AddOption.js';
import { PollList } from './components/PollList.js';
import { ShareSection } from './components/ShareSection.js';
const app = document.getElementById('app');
// Header: logo + status
const header = document.createElement('header');
header.className = 'app-header';
const wordmark = document.createElement('div');
wordmark.className = 'app-wordmark';
wordmark.innerHTML = `
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="2" y="4" width="16" height="2.5" rx="1.25" fill="currentColor"/>
<rect x="2" y="8.75" width="11" height="2.5" rx="1.25" fill="currentColor" opacity="0.6"/>
<rect x="2" y="13.5" width="13" height="2.5" rx="1.25" fill="currentColor" opacity="0.35"/>
</svg>
<span>Polly</span>
`;
header.append(wordmark, StatusBar());
// Main card
const card = document.createElement('main');
card.className = 'app-card';
card.append(
PollTitle(),
AddOption(),
PollList(),
);
// Footer
const footer = document.createElement('footer');
footer.className = 'app-footer';
footer.appendChild(ShareSection());
app.append(header, card, footer);

View File

@@ -1,412 +0,0 @@
/* ── Fonts ─────────────────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=Playfair+Display:wght@500&display=swap');
/* ── Tokens ────────────────────────────────────────────── */
:root {
--bg: #F7F6F2;
--surface: #FFFFFF;
--surface-hover: #FAFAF8;
--border: #E8E5DF;
--border-focus: #1A1A1A;
--text-primary: #1A1A1A;
--text-secondary: #6B6860;
--text-muted: #AAA79F;
--accent: #1A1A1A;
--accent-text: #FFFFFF;
--vote-bar: rgba(26, 26, 26, 0.07);
--vote-bar-voted: rgba(26, 26, 26, 0.12);
--success: #2D7D46;
--danger: #C0392B;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--font-display: 'Playfair Display', Georgia, serif;
--font-body: 'DM Sans', system-ui, sans-serif;
--shadow-card: 0 1px 3px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.05);
}
/* ── Reset ─────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Base ──────────────────────────────────────────────── */
html { font-size: 16px; }
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--text-primary);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* ── Layout ────────────────────────────────────────────── */
#app {
max-width: 580px;
margin: 0 auto;
padding: 2rem 1.25rem 4rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Header ────────────────────────────────────────────── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.25rem;
}
.app-wordmark {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
/* ── Status bar ────────────────────────────────────────── */
.status-bar {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
transition: background 0.3s;
}
.status-dot.connecting { background: var(--text-muted); }
.status-dot.ready { background: var(--text-muted); }
.status-dot.connected { background: var(--success); }
.status-divider { color: var(--text-muted); }
/* ── Card ──────────────────────────────────────────────── */
.app-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
/* ── Poll Title ────────────────────────────────────────── */
.poll-title-wrapper {
padding: 1.75rem 1.75rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.poll-title-input {
width: 100%;
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 500;
color: var(--text-primary);
background: transparent;
border: none;
outline: none;
line-height: 1.3;
letter-spacing: -0.02em;
}
.poll-title-input::placeholder { color: var(--text-muted); }
/* ── Add Option ────────────────────────────────────────── */
.add-option-wrapper {
display: flex;
gap: 0.625rem;
padding: 1.25rem 1.75rem;
border-bottom: 1px solid var(--border);
}
.add-option-input {
flex: 1;
height: 2.5rem;
padding: 0 0.875rem;
font-family: var(--font-body);
font-size: 0.9rem;
color: var(--text-primary);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.15s;
}
.add-option-input::placeholder { color: var(--text-muted); }
.add-option-input:focus { border-color: var(--border-focus); }
.add-option-input.shake {
animation: shake 0.3s ease;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
.add-option-btn {
display: flex;
align-items: center;
gap: 0.375rem;
height: 2.5rem;
padding: 0 1rem;
font-family: var(--font-body);
font-size: 0.875rem;
font-weight: 500;
color: var(--accent-text);
background: var(--accent);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: opacity 0.15s;
white-space: nowrap;
}
.add-option-btn:hover { opacity: 0.85; }
.add-option-btn:active { opacity: 0.7; }
/* ── Poll List ─────────────────────────────────────────── */
.poll-list-wrapper {
padding: 0.5rem 0;
}
.poll-list-meta {
padding: 0.5rem 1.75rem 0.75rem;
font-size: 0.775rem;
color: var(--text-muted);
letter-spacing: 0.02em;
text-transform: uppercase;
font-weight: 500;
}
.poll-list-empty {
padding: 3rem 1.75rem;
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
}
.empty-icon {
margin-bottom: 0.75rem;
opacity: 0.6;
}
/* ── Poll Option ───────────────────────────────────────── */
.poll-option {
position: relative;
overflow: hidden;
transition: background 0.15s;
}
.poll-option:hover {
background: var(--surface-hover);
}
.poll-option__bar {
position: absolute;
inset: 0 auto 0 0;
background: var(--vote-bar);
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.poll-option--voted .poll-option__bar {
background: var(--vote-bar-voted);
}
.poll-option__content {
position: relative;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1.75rem;
}
.poll-option__name {
flex: 1;
font-size: 0.9375rem;
font-weight: 400;
color: var(--text-primary);
word-break: break-word;
}
.poll-option--voted .poll-option__name {
font-weight: 500;
}
.poll-option__actions {
display: flex;
align-items: center;
gap: 0.625rem;
flex-shrink: 0;
}
.poll-option__pct {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
min-width: 2.5rem;
text-align: right;
}
.poll-option__count {
font-size: 0.775rem;
color: var(--text-muted);
min-width: 3.5rem;
}
.poll-option__vote-btn {
height: 1.875rem;
padding: 0 0.875rem;
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.poll-option__vote-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.poll-option--voted .poll-option__vote-btn {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.poll-option--voted .poll-option__vote-btn:hover {
opacity: 0.8;
}
.poll-option__delete-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, color 0.15s, background 0.15s;
}
.poll-option:hover .poll-option__delete-btn { opacity: 1; }
.poll-option__delete-btn:hover {
color: var(--danger);
background: rgba(192, 57, 43, 0.07);
}
/* ── Footer ────────────────────────────────────────────── */
.app-footer {
padding: 0 0.25rem;
}
/* ── Share Section ─────────────────────────────────────── */
.share-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 1.25rem 1.5rem;
}
.share-label {
font-size: 0.775rem;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.625rem;
}
.share-row {
display: flex;
align-items: center;
gap: 0.625rem;
}
.share-url {
flex: 1;
font-family: 'DM Mono', 'Fira Mono', monospace;
font-size: 0.8rem;
color: var(--text-secondary);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.5rem 0.75rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
user-select: all;
}
.share-copy-btn {
height: 2rem;
padding: 0 0.875rem;
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
flex-shrink: 0;
}
.share-copy-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.share-copy-btn--success {
color: var(--success) !important;
border-color: var(--success) !important;
}
/* ── Responsive ────────────────────────────────────────── */
@media (max-width: 480px) {
#app { padding: 1rem 0.75rem 3rem; }
.poll-title-wrapper { padding: 1.25rem 1.25rem 1rem; }
.add-option-wrapper { padding: 1rem 1.25rem; }
.poll-option__content { padding: 0.875rem 1.25rem; }
.poll-list-meta { padding: 0.5rem 1.25rem 0.625rem; }
.poll-list-empty { padding: 2.5rem 1.25rem; }
.poll-option__count { display: none; }
.share-section { padding: 1rem 1.25rem; }
}

View File

@@ -1,79 +0,0 @@
import * as Y from 'yjs';
import { WebrtcProvider } from 'y-webrtc';
// --- Peer ID (stable across reloads) ---
function getOrCreatePeerId() {
let id = localStorage.getItem('peer-id');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('peer-id', id);
}
return id;
}
// --- Room name from URL ---
function getRoomName() {
const params = new URLSearchParams(window.location.search);
return params.get('room') || 'default-poll';
}
// --- Yjs setup ---
export const peerId = getOrCreatePeerId();
export const roomName = getRoomName();
export const ydoc = new Y.Doc();
export const provider = new WebrtcProvider(roomName, ydoc);
export const yOptions = ydoc.getMap('poll-options');
export const yTitle = ydoc.getText('poll-title');
// --- Data operations ---
export function addOption(name) {
const id = crypto.randomUUID();
const optionMap = new Y.Map();
optionMap.set('name', name);
optionMap.set('votes', new Y.Map());
yOptions.set(id, optionMap);
}
export function toggleVote(optionId) {
const optionMap = yOptions.get(optionId);
if (!optionMap) return;
const votes = optionMap.get('votes');
if (votes.has(peerId)) {
votes.delete(peerId);
} else {
votes.set(peerId, true);
}
}
export function deleteOption(optionId) {
yOptions.delete(optionId);
}
// --- Derived read helpers ---
export function getEntries() {
const entries = [];
yOptions.forEach((optionMap, id) => {
entries.push({
id,
name: optionMap.get('name'),
votes: optionMap.get('votes').size,
voted: optionMap.get('votes').has(peerId),
});
});
entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name));
return entries;
}
export function getTotalVotes() {
let total = 0;
yOptions.forEach((optionMap) => {
total += optionMap.get('votes').size;
});
return total;
}