.gitignore π
@@ -0,0 +1 @@
+*.db
Amolith created
Complete WebSocket-based collaborative list application with:
- Deno backend with SQLite database
- Real-time voting system (upvote, downvote, veto)
- Room-based collaboration with unique IDs
- Automatic item sorting by score
- Client-side keyboard shortcuts (j/k nav, voting, edit, delete)
- Light/dark theme support
- Input validation and security constraints
Assisted-by: Claude Sonnet 4.5 via Crush
.gitignore | 1
crush.json | 9
server.ts | 590 ++++++++++++++++++++++++++++++++++++++++++
static/app.js | 675 +++++++++++++++++++++++++++++++++++++++++++++++++
static/index.html | 90 ++++++
static/style.css | 480 ++++++++++++++++++++++++++++++++++
6 files changed, 1,845 insertions(+)
@@ -0,0 +1 @@
+*.db
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://charm.land/crush.json",
+ "lsp": {
+ "deno": {
+ "command": "deno",
+ "args": ["lsp"]
+ }
+ }
+}
@@ -0,0 +1,590 @@
+import { serve } from "https://deno.land/std@0.208.0/http/server.ts";
+import { Database } from "https://deno.land/x/sqlite3@0.11.1/mod.ts";
+
+const db = new Database("lists.db");
+
+// Enable foreign key constraints
+db.exec("PRAGMA foreign_keys = ON;");
+
+// Check schema version and migrate if needed
+const currentVersion = (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version;
+
+if (currentVersion < 1) {
+ console.log('π Migrating database schema to version 1...');
+
+ try {
+ db.exec('BEGIN');
+
+ // Temporarily disable foreign keys for migration
+ db.exec('PRAGMA foreign_keys = OFF');
+
+ // Create new tables with constraints
+ db.exec(`
+ CREATE TABLE rooms_new (
+ code TEXT PRIMARY KEY NOT NULL,
+ created_at INTEGER DEFAULT (unixepoch())
+ );
+
+ CREATE TABLE items_new (
+ id TEXT PRIMARY KEY NOT NULL,
+ room_code TEXT NOT NULL,
+ text TEXT NOT NULL CHECK(LENGTH(text) BETWEEN 1 AND 200),
+ created_at INTEGER DEFAULT (unixepoch()),
+ FOREIGN KEY (room_code) REFERENCES rooms_new(code) ON DELETE CASCADE
+ );
+
+ CREATE TABLE votes_new (
+ item_id TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ vote_type TEXT NOT NULL CHECK(vote_type IN ('up', 'down', 'veto')),
+ created_at INTEGER DEFAULT (unixepoch()),
+ PRIMARY KEY (item_id, user_id),
+ FOREIGN KEY (item_id) REFERENCES items_new(id) ON DELETE CASCADE
+ );
+ `);
+
+ // Copy data if old tables exist, filtering/truncating as needed
+ const tableExists = (name: string) => {
+ const result = db.prepare(`
+ SELECT name FROM sqlite_master
+ WHERE type='table' AND name=?
+ `).get(name);
+ return result !== undefined;
+ };
+
+ if (tableExists('rooms')) {
+ db.exec(`
+ INSERT INTO rooms_new (code, created_at)
+ SELECT code, created_at
+ FROM rooms
+ `);
+ }
+
+ if (tableExists('items')) {
+ db.exec(`
+ INSERT INTO items_new (id, room_code, text, created_at)
+ SELECT i.id, i.room_code,
+ SUBSTR(TRIM(REPLACE(REPLACE(i.text, char(13)||char(10), char(10)), char(13), char(10))), 1, 200) as text,
+ i.created_at
+ FROM items i
+ WHERE i.room_code IN (SELECT code FROM rooms_new)
+ AND LENGTH(TRIM(i.text)) > 0
+ `);
+ }
+
+ if (tableExists('votes')) {
+ db.exec(`
+ INSERT INTO votes_new (item_id, user_id, vote_type, created_at)
+ SELECT v.item_id, v.user_id, v.vote_type, v.created_at
+ FROM votes v
+ WHERE v.item_id IN (SELECT id FROM items_new)
+ AND v.vote_type IN ('up', 'down', 'veto')
+ `);
+ }
+
+ // Drop old tables if they exist
+ if (tableExists('votes')) db.exec('DROP TABLE votes');
+ if (tableExists('items')) db.exec('DROP TABLE items');
+ if (tableExists('rooms')) db.exec('DROP TABLE rooms');
+
+ // Rename new tables
+ db.exec('ALTER TABLE rooms_new RENAME TO rooms');
+ db.exec('ALTER TABLE items_new RENAME TO items');
+ db.exec('ALTER TABLE votes_new RENAME TO votes');
+
+ // Create indexes
+ db.exec(`
+ CREATE INDEX idx_items_room_code ON items(room_code);
+ CREATE INDEX idx_votes_item_id ON votes(item_id);
+ CREATE INDEX idx_votes_user_id ON votes(user_id);
+ `);
+
+ // Re-enable foreign keys
+ db.exec('PRAGMA foreign_keys = ON');
+
+ // Set schema version
+ db.exec('PRAGMA user_version = 2');
+
+ db.exec('COMMIT');
+ console.log('β
Migration complete');
+ } catch (err) {
+ db.exec('ROLLBACK');
+ console.error('β Migration failed:', err);
+ throw err;
+ }
+} else if (currentVersion === 1) {
+ console.log('π Migrating database schema from version 1 to 2...');
+
+ try {
+ db.exec('BEGIN');
+ db.exec('PRAGMA foreign_keys = OFF');
+
+ // Create new rooms table without last_activity and length constraint
+ db.exec(`
+ CREATE TABLE rooms_new (
+ code TEXT PRIMARY KEY NOT NULL,
+ created_at INTEGER DEFAULT (unixepoch())
+ );
+ `);
+
+ // Copy existing rooms
+ db.exec(`
+ INSERT INTO rooms_new (code, created_at)
+ SELECT code, created_at
+ FROM rooms
+ `);
+
+ // Drop old and rename
+ db.exec('DROP TABLE rooms');
+ db.exec('ALTER TABLE rooms_new RENAME TO rooms');
+
+ db.exec('PRAGMA foreign_keys = ON');
+ db.exec('PRAGMA user_version = 2');
+ db.exec('COMMIT');
+
+ console.log('β
Migration from v1 to v2 complete');
+ } catch (err) {
+ db.exec('ROLLBACK');
+ console.error('β Migration failed:', err);
+ throw err;
+ }
+} else if (currentVersion === 2) {
+ console.log('π Migrating database schema from version 2 to 3...');
+
+ try {
+ db.exec('BEGIN');
+
+ // Add title column to rooms table
+ db.exec('ALTER TABLE rooms ADD COLUMN title TEXT');
+
+ db.exec('PRAGMA user_version = 3');
+ db.exec('COMMIT');
+
+ console.log('β
Migration from v2 to v3 complete');
+ } catch (err) {
+ db.exec('ROLLBACK');
+ console.error('β Migration failed:', err);
+ throw err;
+ }
+} else {
+ // Plant the schema if soil is fresh (for new databases)
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS rooms (
+ code TEXT PRIMARY KEY NOT NULL,
+ created_at INTEGER DEFAULT (unixepoch()),
+ title TEXT
+ );
+
+ CREATE TABLE IF NOT EXISTS items (
+ id TEXT PRIMARY KEY NOT NULL,
+ room_code TEXT NOT NULL,
+ text TEXT NOT NULL CHECK(LENGTH(text) BETWEEN 1 AND 200),
+ created_at INTEGER DEFAULT (unixepoch()),
+ FOREIGN KEY (room_code) REFERENCES rooms(code) ON DELETE CASCADE
+ );
+
+ CREATE TABLE IF NOT EXISTS votes (
+ item_id TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ vote_type TEXT NOT NULL CHECK(vote_type IN ('up', 'down', 'veto')),
+ created_at INTEGER DEFAULT (unixepoch()),
+ PRIMARY KEY (item_id, user_id),
+ FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
+ );
+ `);
+}
+
+const clients = new Map<string, Set<WebSocket>>();
+
+function generateRoomId(): string {
+ return crypto.randomUUID();
+}
+
+function broadcast(roomCode: string, message: unknown, except?: WebSocket) {
+ const room = clients.get(roomCode);
+ if (!room) return;
+
+ const payload = JSON.stringify(message);
+ for (const client of room) {
+ if (client !== except && client.readyState === WebSocket.OPEN) {
+ client.send(payload);
+ }
+ }
+}
+
+// Validation constants
+const MAX_ITEM_TEXT_LEN = 200;
+const MAX_BULK_ITEMS = 100;
+const MAX_WS_MESSAGE_BYTES = 32768;
+
+// Validation helpers
+function safeParseMessage(ws: WebSocket, raw: string): unknown | null {
+ if (raw.length > MAX_WS_MESSAGE_BYTES) {
+ console.warn(`Message too large: ${raw.length} bytes`);
+ ws.close(1009, 'Message too large');
+ return null;
+ }
+
+ try {
+ return JSON.parse(raw);
+ } catch (err) {
+ console.warn('Invalid JSON:', err);
+ return null;
+ }
+}
+
+function sanitizeItemText(s: string): string | null {
+ // Trim and collapse internal CRLF to \n
+ const cleaned = s.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+
+ if (cleaned.length < 1 || cleaned.length > MAX_ITEM_TEXT_LEN) {
+ return null;
+ }
+
+ return cleaned;
+}
+
+function isValidVoteType(x: unknown): x is 'up' | 'down' | 'veto' {
+ return x === 'up' || x === 'down' || x === 'veto';
+}
+
+function itemBelongsToRoom(itemId: string, roomCode: string): boolean {
+ const result = db.prepare('SELECT 1 FROM items WHERE id = ? AND room_code = ?').get(itemId, roomCode);
+ return result !== undefined;
+}
+
+function getState(roomCode: string) {
+ const room = db.prepare('SELECT title FROM rooms WHERE code = ?').get(roomCode) as { title: string | null } | undefined;
+ const roomTitle = room?.title || null;
+
+ const items = db.prepare(`
+ SELECT i.id, i.text, i.created_at,
+ GROUP_CONCAT(v.user_id || ':' || v.vote_type) as votes
+ FROM items i
+ LEFT JOIN votes v ON i.id = v.item_id
+ WHERE i.room_code = ?
+ GROUP BY i.id
+ ORDER BY i.created_at
+ `).all(roomCode) as Array<{id: string, text: string, created_at: number, votes: string | null}>;
+
+ return {
+ roomTitle,
+ items: items.map(item => ({
+ id: item.id,
+ text: item.text,
+ votes: item.votes ? Object.fromEntries(
+ item.votes.split(',').map(v => {
+ const [userId, voteType] = v.split(':');
+ return [userId, voteType];
+ })
+ ) : {}
+ }))
+ };
+}
+
+function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
+ // Add to room
+ if (!clients.has(roomCode)) {
+ clients.set(roomCode, new Set());
+ }
+ clients.get(roomCode)!.add(ws);
+
+ ws.onopen = () => {
+ // Send current state once connection is open
+ const state = getState(roomCode);
+ ws.send(JSON.stringify({
+ type: 'state',
+ items: state.items,
+ roomTitle: state.roomTitle,
+ userId
+ }));
+ };
+
+ ws.onmessage = (event) => {
+ const msg = safeParseMessage(ws, event.data);
+ if (!msg || typeof msg !== 'object') return;
+
+ try {
+ switch ((msg as any).type) {
+ case 'add_items': {
+ const rawItems = (msg as any).items;
+ if (!Array.isArray(rawItems)) {
+ console.warn('add_items: items is not an array');
+ return;
+ }
+
+ if (rawItems.length < 1 || rawItems.length > MAX_BULK_ITEMS) {
+ console.warn(`add_items: invalid count ${rawItems.length}`);
+ return;
+ }
+
+ const items = rawItems
+ .map((text: unknown) => {
+ if (typeof text !== 'string') return null;
+ const sanitized = sanitizeItemText(text);
+ return sanitized ? { id: crypto.randomUUID(), text: sanitized } : null;
+ })
+ .filter((item): item is { id: string; text: string } => item !== null);
+
+ if (items.length === 0) {
+ console.warn('add_items: no valid items after sanitization');
+ return;
+ }
+
+ try {
+ db.exec('BEGIN IMMEDIATE');
+ const stmt = db.prepare('INSERT INTO items (id, room_code, text) VALUES (?, ?, ?)');
+ for (const item of items) {
+ stmt.run(item.id, roomCode, item.text);
+ }
+ db.exec('COMMIT');
+ } catch (err) {
+ db.exec('ROLLBACK');
+ console.error('add_items transaction failed:', err);
+ return;
+ }
+
+ broadcast(roomCode, {
+ type: 'items_added',
+ items: items.map(i => ({ ...i, votes: {} }))
+ });
+ break;
+ }
+
+ case 'vote': {
+ const { itemId, voteType } = msg as any;
+
+ if (typeof itemId !== 'string') {
+ console.warn('vote: itemId is not a string');
+ return;
+ }
+
+ if (!isValidVoteType(voteType)) {
+ console.warn(`vote: invalid voteType ${voteType}`);
+ return;
+ }
+
+ if (!itemBelongsToRoom(itemId, roomCode)) {
+ console.warn(`vote: item ${itemId} does not belong to room ${roomCode}`);
+ return;
+ }
+
+ db.prepare(`
+ INSERT INTO votes (item_id, user_id, vote_type)
+ VALUES (?, ?, ?)
+ ON CONFLICT (item_id, user_id)
+ DO UPDATE SET vote_type = excluded.vote_type
+ `).run(itemId, userId, voteType);
+
+ broadcast(roomCode, {
+ type: 'vote_changed',
+ itemId,
+ userId,
+ voteType
+ });
+ break;
+ }
+
+ case 'unvote': {
+ const { itemId } = msg as any;
+
+ if (typeof itemId !== 'string') {
+ console.warn('unvote: itemId is not a string');
+ return;
+ }
+
+ if (!itemBelongsToRoom(itemId, roomCode)) {
+ console.warn(`unvote: item ${itemId} does not belong to room ${roomCode}`);
+ return;
+ }
+
+ db.prepare('DELETE FROM votes WHERE item_id = ? AND user_id = ?')
+ .run(itemId, userId);
+
+ broadcast(roomCode, {
+ type: 'vote_removed',
+ itemId,
+ userId
+ });
+ break;
+ }
+
+ case 'edit_item': {
+ const { itemId, text } = msg as any;
+
+ if (typeof itemId !== 'string') {
+ console.warn('edit_item: itemId is not a string');
+ return;
+ }
+
+ if (typeof text !== 'string') {
+ console.warn('edit_item: text is not a string');
+ return;
+ }
+
+ const sanitized = sanitizeItemText(text);
+ if (!sanitized) {
+ console.warn('edit_item: text failed sanitization');
+ return;
+ }
+
+ if (!itemBelongsToRoom(itemId, roomCode)) {
+ console.warn(`edit_item: item ${itemId} does not belong to room ${roomCode}`);
+ return;
+ }
+
+ db.prepare('UPDATE items SET text = ? WHERE id = ?')
+ .run(sanitized, itemId);
+
+ broadcast(roomCode, {
+ type: 'item_edited',
+ itemId,
+ text: sanitized
+ });
+ break;
+ }
+
+ case 'delete_item': {
+ const { itemId } = msg as any;
+
+ if (typeof itemId !== 'string') {
+ console.warn('delete_item: itemId is not a string');
+ return;
+ }
+
+ if (!itemBelongsToRoom(itemId, roomCode)) {
+ console.warn(`delete_item: item ${itemId} does not belong to room ${roomCode}`);
+ return;
+ }
+
+ // Delete votes first (foreign key constraint)
+ db.prepare('DELETE FROM votes WHERE item_id = ?').run(itemId);
+ // Delete the item
+ db.prepare('DELETE FROM items WHERE id = ?').run(itemId);
+
+ broadcast(roomCode, {
+ type: 'item_deleted',
+ itemId
+ });
+ break;
+ }
+
+ case 'reset_votes': {
+ // Delete all votes for all items in this room
+ db.prepare(`
+ DELETE FROM votes
+ WHERE item_id IN (
+ SELECT id FROM items WHERE room_code = ?
+ )
+ `).run(roomCode);
+
+ broadcast(roomCode, {
+ type: 'votes_reset'
+ });
+ break;
+ }
+
+ case 'set_title': {
+ const { title } = msg as any;
+
+ let sanitized: string | null = null;
+ if (title !== null && title !== undefined) {
+ if (typeof title !== 'string') {
+ console.warn('set_title: title is not a string');
+ return;
+ }
+ sanitized = sanitizeItemText(title);
+ if (!sanitized) {
+ console.warn('set_title: title failed sanitization');
+ return;
+ }
+ }
+
+ db.prepare('UPDATE rooms SET title = ? WHERE code = ?')
+ .run(sanitized, roomCode);
+
+ broadcast(roomCode, {
+ type: 'title_changed',
+ title: sanitized
+ });
+ break;
+ }
+ }
+ } catch (err) {
+ console.error('Message handling error:', err);
+ }
+ };
+
+ ws.onclose = () => {
+ const room = clients.get(roomCode);
+ if (room) {
+ room.delete(ws);
+ if (room.size === 0) {
+ clients.delete(roomCode);
+ }
+ }
+ };
+}
+
+serve(async (req) => {
+ const url = new URL(req.url);
+
+ // Serve static files
+ if (url.pathname === "/" || url.pathname === "/index.html") {
+ const html = await Deno.readTextFile("./static/index.html");
+ return new Response(html, { headers: { "content-type": "text/html" } });
+ }
+ if (url.pathname === "/style.css") {
+ const css = await Deno.readTextFile("./static/style.css");
+ return new Response(css, { headers: { "content-type": "text/css" } });
+ }
+ if (url.pathname === "/app.js") {
+ const js = await Deno.readTextFile("./static/app.js");
+ return new Response(js, { headers: { "content-type": "application/javascript" } });
+ }
+ if (url.pathname.startsWith("/icons/") && url.pathname.endsWith(".svg")) {
+ const iconName = url.pathname.slice(7); // Remove "/icons/"
+ try {
+ const svg = await Deno.readTextFile(`./static/icons/${iconName}`);
+ return new Response(svg, { headers: { "content-type": "image/svg+xml" } });
+ } catch {
+ return new Response("Not found", { status: 404 });
+ }
+ }
+
+ // Create new room
+ if (url.pathname === "/api/create") {
+ const code = generateRoomId();
+ db.prepare('INSERT INTO rooms (code) VALUES (?)').run(code);
+ return Response.json({ code });
+ }
+
+ // WebSocket connection
+ if (url.pathname === "/ws") {
+ const roomCode = url.searchParams.get("room");
+ const userId = url.searchParams.get("user") || crypto.randomUUID();
+
+ if (!roomCode) {
+ return new Response("Missing room code", { status: 400 });
+ }
+
+ // Verify room exists
+ const roomExists = db.prepare('SELECT code FROM rooms WHERE code = ?').get(roomCode);
+ if (!roomExists) {
+ return new Response("Room not found", { status: 404 });
+ }
+
+ const upgrade = req.headers.get("upgrade") || "";
+ if (upgrade.toLowerCase() !== "websocket") {
+ return new Response("Expected websocket", { status: 426 });
+ }
+
+ const { socket, response } = Deno.upgradeWebSocket(req);
+ handleWebSocket(socket, roomCode, userId);
+ return response;
+ }
+
+ return new Response("Not found", { status: 404 });
+}, { port: 8294 });
+
+console.log("πΏ Sift ready at http://localhost:8294");
@@ -0,0 +1,675 @@
+let ws = null;
+let currentRoom = null;
+let roomTitle = null;
+let userId = localStorage.getItem('userId') || crypto.randomUUID();
+localStorage.setItem('userId', userId);
+
+let items = [];
+let lastAction = null; // For undo
+let selectedItemId = null; // For keyboard navigation
+let selectedPosition = null; // Position to restore after voting
+let lastSyncTime = null; // For connection status
+let isReady = false; // Track if initial state received
+
+const startScreen = document.getElementById('start-screen');
+const listScreen = document.getElementById('list-screen');
+const roomTitleEl = document.getElementById('room-title');
+const listContainer = document.getElementById('list-container');
+
+// Start screen handlers
+document.getElementById('create-btn').addEventListener('click', async () => {
+ const res = await fetch('/api/create');
+ const { code } = await res.json();
+ joinRoom(code);
+});
+
+document.getElementById('join-btn').addEventListener('click', () => {
+ const code = document.getElementById('join-code').value.trim();
+ if (code) joinRoom(code);
+});
+
+document.getElementById('join-code').addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.repeat) {
+ e.preventDefault();
+ const code = e.target.value.trim();
+ if (code) joinRoom(code);
+ }
+});
+
+document.getElementById('leave-btn').addEventListener('click', () => {
+ if (ws) ws.close();
+ startScreen.classList.remove('hidden');
+ listScreen.classList.add('hidden');
+ currentRoom = null;
+ items = [];
+ messageQueue = [];
+ listContainer.innerHTML = '';
+});
+
+document.getElementById('copy-btn').addEventListener('click', async () => {
+ const itemNames = items.map(item => item.text).join('\n');
+ try {
+ await navigator.clipboard.writeText(itemNames);
+ const btn = document.getElementById('copy-btn');
+ const original = btn.textContent;
+ btn.textContent = 'β';
+ setTimeout(() => btn.textContent = original, 1000);
+ } catch (err) {
+ console.error('Failed to copy:', err);
+ }
+});
+
+document.getElementById('copy-link-btn').addEventListener('click', async () => {
+ const inviteLink = `${location.origin}${location.pathname}?room=${currentRoom}`;
+ try {
+ await navigator.clipboard.writeText(inviteLink);
+ const btn = document.getElementById('copy-link-btn');
+ const original = btn.textContent;
+ btn.textContent = 'β';
+ setTimeout(() => btn.textContent = original, 1000);
+ } catch (err) {
+ console.error('Failed to copy invite link:', err);
+ }
+});
+
+document.getElementById('reset-votes-btn').addEventListener('click', () => {
+ if (confirm('Clear all votes and vetoes from all items?')) {
+ sendMessage({ type: 'reset_votes' });
+ }
+});
+
+document.getElementById('set-title-btn').addEventListener('click', () => {
+ const currentText = roomTitle || '';
+
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.className = 'title-edit-input';
+ input.value = currentText;
+ input.placeholder = 'Enter room title';
+
+ const finishEdit = () => {
+ const newText = input.value.trim();
+ if (newText !== currentText) {
+ setTitle(newText || null);
+ }
+ input.remove();
+ };
+
+ input.addEventListener('blur', finishEdit);
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.repeat) {
+ e.preventDefault();
+ input.blur();
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ input.remove();
+ }
+ });
+
+ // Insert input after the header div
+ const header = listScreen.querySelector('header');
+ if (roomTitleEl.classList.contains('hidden')) {
+ roomTitleEl.parentElement.insertBefore(input, roomTitleEl);
+ } else {
+ roomTitleEl.style.display = 'none';
+ roomTitleEl.parentElement.insertBefore(input, roomTitleEl);
+ }
+
+ input.focus();
+ input.select();
+});
+
+// Add items handlers
+document.getElementById('add-single-btn').addEventListener('click', () => {
+ const input = document.getElementById('single-input');
+ const text = input.value.trim();
+ if (text) {
+ sendMessage({ type: 'add_items', items: [text] });
+ input.value = '';
+ }
+});
+
+document.getElementById('single-input').addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.repeat) {
+ e.preventDefault();
+ const text = e.target.value.trim();
+ if (text) {
+ sendMessage({ type: 'add_items', items: [text] });
+ e.target.value = '';
+ }
+ }
+});
+
+document.getElementById('add-bulk-btn').addEventListener('click', () => {
+ const textarea = document.getElementById('bulk-input');
+ const lines = textarea.value.split('\n')
+ .map(l => l.trim())
+ .filter(l => l);
+ if (lines.length) {
+ sendMessage({ type: 'add_items', items: lines });
+ textarea.value = '';
+ }
+});
+
+// Core functions
+let messageQueue = [];
+
+function joinRoom(code) {
+ currentRoom = code;
+
+ isReady = false;
+ setUIEnabled(false);
+ updateConnectionStatus('connecting');
+
+ const wsScheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ ws = new WebSocket(`${wsScheme}//${location.host}/ws?room=${code}&user=${userId}`);
+
+ ws.onopen = () => {
+ updateConnectionStatus('connected');
+ // Flush queued messages
+ while (messageQueue.length > 0) {
+ ws.send(JSON.stringify(messageQueue.shift()));
+ }
+ };
+
+ ws.onmessage = (event) => {
+ const msg = JSON.parse(event.data);
+ lastSyncTime = Date.now();
+ updateLastSync();
+ handleMessage(msg);
+ };
+
+ ws.onerror = (error) => {
+ console.error('WebSocket error:', error);
+ updateConnectionStatus('disconnected');
+ };
+
+ ws.onclose = (event) => {
+ isReady = false;
+ setUIEnabled(false);
+ updateConnectionStatus('disconnected');
+ if (!event.wasClean) {
+ console.error('Connection closed unexpectedly');
+ alert('Connection lost. Please try rejoining.');
+ startScreen.classList.remove('hidden');
+ listScreen.classList.add('hidden');
+ currentRoom = null;
+ items = [];
+ messageQueue = [];
+ listContainer.innerHTML = '';
+ }
+ };
+
+ startScreen.classList.add('hidden');
+ listScreen.classList.remove('hidden');
+}
+
+function sendMessage(msg) {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify(msg));
+ } else {
+ messageQueue.push(msg);
+ }
+}
+
+function handleMessage(msg) {
+ switch (msg.type) {
+ case 'state':
+ items = msg.items;
+ userId = msg.userId;
+ roomTitle = msg.roomTitle;
+ if (items.length > 0 && !selectedItemId) {
+ selectedItemId = getSortedItems()[0].id;
+ }
+ isReady = true;
+ setUIEnabled(true);
+ renderTitle();
+ render();
+ break;
+ case 'items_added':
+ items.push(...msg.items);
+ if (!selectedItemId && items.length > 0) {
+ selectedItemId = getSortedItems()[0].id;
+ }
+ render();
+ break;
+ case 'vote_changed':
+ const item = items.find(i => i.id === msg.itemId);
+ if (item) {
+ item.votes[msg.userId] = msg.voteType;
+ // If we voted, restore selection to same position
+ if (msg.userId === userId && selectedPosition !== null) {
+ const sorted = getSortedItems();
+ const clampedPos = Math.min(selectedPosition, sorted.length - 1);
+ selectedItemId = sorted[clampedPos].id;
+ selectedPosition = null;
+ }
+ render();
+ }
+ break;
+ case 'vote_removed':
+ const item2 = items.find(i => i.id === msg.itemId);
+ if (item2) {
+ delete item2.votes[msg.userId];
+ // If we unvoted, restore selection to same position
+ if (msg.userId === userId && selectedPosition !== null) {
+ const sorted = getSortedItems();
+ const clampedPos = Math.min(selectedPosition, sorted.length - 1);
+ selectedItemId = sorted[clampedPos].id;
+ selectedPosition = null;
+ }
+ render();
+ }
+ break;
+ case 'item_edited':
+ const editedItem = items.find(i => i.id === msg.itemId);
+ if (editedItem) {
+ editedItem.text = msg.text;
+ render();
+ }
+ break;
+ case 'item_deleted':
+ if (selectedItemId === msg.itemId) {
+ const sorted = getSortedItems();
+ const idx = sorted.findIndex(i => i.id === msg.itemId);
+ const nextIdx = Math.min(idx, sorted.length - 2);
+ selectedItemId = nextIdx >= 0 ? sorted[nextIdx].id : null;
+ }
+ items = items.filter(i => i.id !== msg.itemId);
+ render();
+ break;
+ case 'votes_reset':
+ items.forEach(item => {
+ item.votes = {};
+ });
+ render();
+ break;
+ case 'title_changed':
+ roomTitle = msg.title;
+ renderTitle();
+ break;
+ }
+}
+
+function vote(itemId, voteType) {
+ const item = items.find(i => i.id === itemId);
+ const currentVote = item?.votes[userId];
+
+ // Remember current position to select item at same position after re-sort
+ const sorted = getSortedItems();
+ selectedPosition = sorted.findIndex(i => i.id === itemId);
+
+ lastAction = { type: 'vote', itemId, previousVote: currentVote };
+
+ if (currentVote === voteType) {
+ sendMessage({ type: 'unvote', itemId });
+ } else {
+ sendMessage({ type: 'vote', itemId, voteType });
+ }
+}
+
+function deleteItem(itemId) {
+ if (confirm('Delete this item?')) {
+ sendMessage({ type: 'delete_item', itemId });
+ }
+}
+
+function editItem(itemId) {
+ const item = items.find(i => i.id === itemId);
+ if (!item) return;
+
+ const itemEl = document.querySelector(`[data-item-id="${itemId}"]`);
+ if (!itemEl) return;
+
+ const textEl = itemEl.querySelector('.list-item-text');
+ const currentText = item.text;
+
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.className = 'edit-input';
+ input.value = currentText;
+
+ const finishEdit = () => {
+ const newText = input.value.trim();
+ if (newText && newText !== currentText) {
+ sendMessage({ type: 'edit_item', itemId, text: newText });
+ }
+ textEl.textContent = item.text;
+ textEl.style.display = '';
+ input.remove();
+ };
+
+ input.addEventListener('blur', finishEdit);
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.repeat) {
+ e.preventDefault();
+ input.blur();
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ textEl.textContent = item.text;
+ textEl.style.display = '';
+ input.remove();
+ }
+ });
+
+ textEl.style.display = 'none';
+ textEl.parentElement.insertBefore(input, textEl);
+ input.focus();
+ input.select();
+}
+
+function undo() {
+ if (!lastAction) return;
+
+ const { itemId, previousVote } = lastAction;
+
+ if (previousVote) {
+ sendMessage({ type: 'vote', itemId, voteType: previousVote });
+ } else {
+ sendMessage({ type: 'unvote', itemId });
+ }
+
+ lastAction = null;
+}
+
+function renderTitle() {
+ roomTitleEl.style.display = '';
+ if (roomTitle) {
+ roomTitleEl.textContent = roomTitle;
+ roomTitleEl.classList.remove('hidden');
+ } else {
+ roomTitleEl.classList.add('hidden');
+ }
+}
+
+function setTitle(title) {
+ sendMessage({ type: 'set_title', title });
+}
+
+// Keyboard shortcuts
+document.addEventListener('keydown', (e) => {
+ // Open help modal on '?'
+ if (e.key === '?' && !e.target.matches('input, textarea')) {
+ e.preventDefault();
+ openHelpModal();
+ return;
+ }
+
+ // Close help modal on Esc
+ if (e.key === 'Escape') {
+ const helpModal = document.getElementById('help-modal');
+ if (!helpModal.classList.contains('hidden')) {
+ e.preventDefault();
+ closeHelpModal();
+ return;
+ }
+ }
+
+ // Ignore if typing in input/textarea
+ if (e.target.matches('input, textarea')) return;
+
+ // Undo
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
+ e.preventDefault();
+ undo();
+ return;
+ }
+
+ // Navigation and actions only work if we have items
+ if (items.length === 0) return;
+
+ const sorted = getSortedItems();
+
+ // Navigation: j/k or ArrowDown/ArrowUp
+ if (e.key === 'j' || e.key === 'ArrowDown') {
+ e.preventDefault();
+ const currentIdx = sorted.findIndex(i => i.id === selectedItemId);
+ const nextIdx = Math.min(currentIdx + 1, sorted.length - 1);
+ selectedItemId = sorted[nextIdx].id;
+ render();
+ } else if (e.key === 'k' || e.key === 'ArrowUp') {
+ e.preventDefault();
+ const currentIdx = sorted.findIndex(i => i.id === selectedItemId);
+ const prevIdx = Math.max(currentIdx - 1, 0);
+ selectedItemId = sorted[prevIdx].id;
+ render();
+ }
+
+ // Actions on selected item
+ if (selectedItemId) {
+ if (e.key === '1' || e.key === 'Enter') {
+ e.preventDefault();
+ vote(selectedItemId, 'up');
+ } else if (e.key === '2') {
+ e.preventDefault();
+ vote(selectedItemId, 'down');
+ } else if (e.key === '3') {
+ e.preventDefault();
+ vote(selectedItemId, 'veto');
+ } else if (e.key === 'e') {
+ e.preventDefault();
+ editItem(selectedItemId);
+ } else if (e.key === 'Delete' || e.key === 'Backspace') {
+ e.preventDefault();
+ deleteItem(selectedItemId);
+ }
+ }
+});
+
+
+
+// Help modal
+const helpModal = document.getElementById('help-modal');
+const helpModalClose = helpModal.querySelector('.modal-close');
+
+function openHelpModal() {
+ helpModal.classList.remove('hidden');
+ helpModalClose.focus();
+}
+
+function closeHelpModal() {
+ helpModal.classList.add('hidden');
+}
+
+helpModalClose.addEventListener('click', closeHelpModal);
+
+// Close modal on backdrop click
+helpModal.addEventListener('click', (e) => {
+ if (e.target === helpModal) {
+ closeHelpModal();
+ }
+});
+
+// Connection status
+function updateConnectionStatus(status) {
+ const statusDot = document.querySelector('.status-dot');
+ const statusText = document.querySelector('.status-text');
+
+ statusDot.className = 'status-dot ' + status;
+
+ const statusLabels = {
+ connecting: 'Connecting...',
+ connected: 'Connected',
+ disconnected: 'Disconnected'
+ };
+
+ statusText.textContent = statusLabels[status] || status;
+}
+
+function formatTimeSince(timestamp) {
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
+
+ if (seconds < 5) return 'just now';
+ if (seconds < 60) return `${seconds}s ago`;
+
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+
+ const days = Math.floor(hours / 24);
+ return `${days}d ago`;
+}
+
+function updateLastSync() {
+ const lastSyncEl = document.querySelector('.last-sync');
+ if (!lastSyncTime) {
+ lastSyncEl.textContent = '';
+ return;
+ }
+
+ lastSyncEl.textContent = 'β’ ' + formatTimeSince(lastSyncTime);
+}
+
+function setUIEnabled(enabled) {
+ const inputs = listScreen.querySelectorAll('input, textarea, button');
+ inputs.forEach(input => {
+ // Don't disable the leave button
+ if (input.id === 'leave-btn') return;
+ input.disabled = !enabled;
+ });
+
+ if (enabled) {
+ listContainer.classList.remove('disabled');
+ } else {
+ listContainer.classList.add('disabled');
+ }
+}
+
+function getSortedItems() {
+ return [...items].sort((a, b) => {
+ const aVetoed = Object.values(a.votes).includes('veto');
+ const bVetoed = Object.values(b.votes).includes('veto');
+ if (aVetoed && !bVetoed) return 1;
+ if (!aVetoed && bVetoed) return -1;
+ const aScore = Object.values(a.votes).reduce((sum, v) =>
+ sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0);
+ const bScore = Object.values(b.votes).reduce((sum, v) =>
+ sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0);
+ return bScore - aScore;
+ });
+}
+
+function render() {
+ // FLIP animation: capture First positions
+ const oldPositions = new Map();
+ const existingItems = listContainer.querySelectorAll('.list-item');
+ existingItems.forEach(el => {
+ const itemId = el.dataset.itemId;
+ oldPositions.set(itemId, el.getBoundingClientRect());
+ });
+
+ // Sort: vetoed items last, then by score
+ const sorted = getSortedItems();
+
+ listContainer.innerHTML = sorted.map(item => {
+ const myVote = item.votes[userId];
+ const score = Object.values(item.votes).reduce((sum, v) =>
+ sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0);
+ const isVetoed = Object.values(item.votes).includes('veto');
+ const isSelected = item.id === selectedItemId;
+
+ return `
+ <div class="list-item ${isVetoed ? 'vetoed' : ''} ${isSelected ? 'selected' : ''}" data-item-id="${item.id}">
+ <div class="list-item-text">${escapeHtml(item.text)}</div>
+ <div class="list-item-actions">
+ <div class="score">${score > 0 ? '+' : ''}${score}</div>
+ <button class="vote-btn ${myVote === 'up' ? 'active' : ''}"
+ data-action="vote" data-vote-type="up" title="Upvote"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"></path></svg></button>
+ <button class="vote-btn ${myVote === 'down' ? 'active' : ''}"
+ data-action="vote" data-vote-type="down" title="Downvote"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"></path></svg></button>
+ <button class="vote-btn ${myVote === 'veto' ? 'veto-active' : ''}"
+ data-action="vote" data-vote-type="veto" title="Veto"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line></svg></button>
+ <button class="delete-btn" data-action="delete" title="Delete"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg></button>
+ </div>
+ </div>
+ `;
+ }).join('');
+
+ // FLIP animation: capture Last positions and animate
+ const newItems = listContainer.querySelectorAll('.list-item');
+ newItems.forEach(el => {
+ const itemId = el.dataset.itemId;
+ const oldPos = oldPositions.get(itemId);
+
+ if (oldPos) {
+ const newPos = el.getBoundingClientRect();
+ const deltaY = oldPos.top - newPos.top;
+
+ // Invert: apply transform to make it appear at old position
+ if (deltaY !== 0) {
+ el.style.transform = `translateY(${deltaY}px)`;
+ el.style.transition = 'none';
+
+ // Play: animate to new position
+ requestAnimationFrame(() => {
+ el.style.transition = '';
+ el.style.transform = '';
+ });
+ }
+ }
+ });
+
+ // Scroll selected item into view
+ if (selectedItemId) {
+ const selectedEl = listContainer.querySelector(`[data-item-id="${selectedItemId}"]`);
+ if (selectedEl) {
+ selectedEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+ }
+ }
+}
+
+function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+// Event delegation for list actions
+listContainer.addEventListener('click', (e) => {
+ const button = e.target.closest('[data-action]');
+ if (!button) return;
+
+ const listItem = button.closest('.list-item');
+ if (!listItem) return;
+
+ const itemId = listItem.dataset.itemId;
+ const action = button.dataset.action;
+
+ if (action === 'vote') {
+ const voteType = button.dataset.voteType;
+ vote(itemId, voteType);
+ } else if (action === 'delete') {
+ deleteItem(itemId);
+ }
+});
+
+listContainer.addEventListener('dblclick', (e) => {
+ const textEl = e.target.closest('.list-item-text');
+ if (!textEl) return;
+
+ const listItem = textEl.closest('.list-item');
+ if (!listItem) return;
+
+ const itemId = listItem.dataset.itemId;
+ editItem(itemId);
+});
+
+// Make functions global
+window.vote = vote;
+window.deleteItem = deleteItem;
+window.editItem = editItem;
+
+// Deep linking: auto-join room from URL param
+const urlParams = new URLSearchParams(location.search);
+const roomParam = urlParams.get('room');
+if (roomParam) {
+ const code = roomParam.trim();
+ if (code) {
+ joinRoom(code);
+ }
+}
+
+// Update sync status every second
+setInterval(updateLastSync, 1000);
@@ -0,0 +1,90 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Sift</title>
+ <link rel="stylesheet" href="/style.css">
+</head>
+<body>
+ <div id="app">
+ <div id="start-screen">
+ <h1>Sift</h1>
+ <div class="buttons">
+ <button id="create-btn">Create New List</button>
+ <div class="join-section">
+ <input type="text" id="join-code" placeholder="Enter room ID">
+ <button id="join-btn">Join List</button>
+ </div>
+ </div>
+ </div>
+
+ <div id="list-screen" class="hidden">
+ <header>
+ <div>
+ <h2 id="room-title" class="hidden"></h2>
+ <div class="connection-status">
+ <span class="status-dot"></span>
+ <span class="status-text">Connecting...</span>
+ <span class="last-sync"></span>
+ </div>
+ </div>
+ <div class="header-actions">
+ <button id="set-title-btn" title="Set room title"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg></button>
+ <button id="copy-link-btn" title="Copy invite link"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg></button>
+ <button id="copy-btn" title="Copy items to clipboard"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg></button>
+ <button id="reset-votes-btn" title="Clear all votes and vetoes"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg></button>
+ <button id="leave-btn">Leave</button>
+ </div>
+ </header>
+
+ <div class="add-section">
+ <input type="text" id="single-input" placeholder="Add single item">
+ <button id="add-single-btn">Add</button>
+ </div>
+
+ <div class="bulk-section">
+ <textarea id="bulk-input" placeholder="Paste items (one per line)"></textarea>
+ <button id="add-bulk-btn">Add all</button>
+ </div>
+
+ <div id="list-container"></div>
+ </div>
+
+ <!-- Help Modal -->
+ <div id="help-modal" class="modal hidden" role="dialog" aria-labelledby="help-title" aria-modal="true">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h3 id="help-title">Keyboard Shortcuts</h3>
+ <button class="modal-close" aria-label="Close help">×</button>
+ </div>
+ <div class="modal-body">
+ <h4>Navigation</h4>
+ <ul>
+ <li><kbd>j</kbd> or <kbd>β</kbd> - Select next item</li>
+ <li><kbd>k</kbd> or <kbd>β</kbd> - Select previous item</li>
+ </ul>
+
+ <h4>Actions on Selected Item</h4>
+ <ul>
+ <li><kbd>1</kbd> or <kbd>Enter</kbd> - Upvote</li>
+ <li><kbd>2</kbd> - Downvote</li>
+ <li><kbd>3</kbd> - Veto</li>
+ <li><kbd>e</kbd> - Edit item</li>
+ <li><kbd>Delete</kbd> or <kbd>Backspace</kbd> - Delete item</li>
+ </ul>
+
+ <h4>General</h4>
+ <ul>
+ <li><kbd>Ctrl/Cmd</kbd> + <kbd>Z</kbd> - Undo last vote</li>
+ <li><kbd>?</kbd> - Show this help</li>
+ <li><kbd>Esc</kbd> - Close help</li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <script src="/app.js"></script>
+</body>
+</html>
@@ -0,0 +1,480 @@
+:root {
+ color-scheme: light dark;
+
+ /* Light theme (default) */
+ --bg-page: #f5f5f5;
+ --bg-surface: white;
+ --bg-header-code: #e8f5e0;
+
+ --text-primary: #333;
+ --text-on-accent: white;
+
+ --accent: #5a8c3a;
+ --accent-hover: #4a7230;
+ --accent-dark: #2d5016;
+ --accent-danger: #b22222;
+
+ --border: #ddd;
+ --border-accent: #5a8c3a;
+ --shadow: rgba(0, 0, 0, 0.1);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg-page: #1a1a1a;
+ --bg-surface: #2a2a2a;
+ --bg-header-code: #3a4a34;
+
+ --text-primary: #e0e0e0;
+ --text-on-accent: white;
+
+ --accent: #7ab859;
+ --accent-hover: #8cc96f;
+ --accent-dark: #5a8c3a;
+ --accent-danger: #d84444;
+
+ --border: #444;
+ --border-accent: #7ab859;
+ --shadow: rgba(0, 0, 0, 0.3);
+ }
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: "Atkinson Hyperlegible Next", system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
+ background: var(--bg-page);
+ color: var(--text-primary);
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+#app {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 2rem 1rem;
+}
+
+.hidden {
+ display: none !important;
+}
+
+/* Start screen */
+#start-screen {
+ text-align: center;
+ padding: 4rem 2rem;
+}
+
+#start-screen h1 {
+ margin-bottom: 3rem;
+ color: var(--accent-dark);
+}
+
+.buttons {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ align-items: center;
+}
+
+.join-section {
+ display: flex;
+ gap: 0.5rem;
+}
+
+button {
+ padding: 0.75rem 1.5rem;
+ background: var(--accent);
+ color: var(--text-on-accent);
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 1rem;
+ transition: background 0.2s, transform 0.05s, box-shadow 0.2s;
+}
+
+button:hover {
+ background: var(--accent-hover);
+}
+
+button:active {
+ transform: translateY(1px);
+}
+
+button:focus-visible,
+input:focus-visible,
+textarea:focus-visible {
+ outline: 2px solid var(--border-accent);
+ outline-offset: 2px;
+}
+
+input, textarea {
+ padding: 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 1rem;
+ font-family: inherit;
+ background: var(--bg-surface);
+ color: var(--text-primary);
+}
+
+input::placeholder,
+textarea::placeholder {
+ color: color-mix(in srgb, var(--text-primary) 50%, transparent);
+}
+
+#join-code {
+ width: 200px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+#join-code::placeholder {
+ text-transform: none;
+}
+
+.expiry-notice {
+ font-size: 0.85rem;
+ color: color-mix(in srgb, var(--text-primary) 60%, transparent);
+ margin-top: 1rem;
+}
+
+#start-screen .expiry-notice {
+ text-align: center;
+}
+
+#list-screen .expiry-notice {
+ margin: 0.5rem 0 0 0;
+ font-size: 0.8rem;
+}
+
+/* List screen */
+#list-screen header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 2rem;
+ padding-bottom: 1rem;
+ border-bottom: 2px solid var(--border-accent);
+}
+
+#list-screen header h2 {
+ margin-bottom: 0.25rem;
+}
+
+.connection-status {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.85rem;
+ color: color-mix(in srgb, var(--text-primary) 70%, transparent);
+}
+
+.status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #888;
+}
+
+.status-dot.connected {
+ background: #4caf50;
+ box-shadow: 0 0 4px #4caf50;
+}
+
+.status-dot.connecting {
+ background: #ff9800;
+ animation: pulse 1.5s ease-in-out infinite;
+}
+
+.status-dot.disconnected {
+ background: #f44336;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+.header-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+#room-code {
+ font-family: monospace;
+ background: var(--bg-header-code);
+ padding: 0.25rem 0.5rem;
+ border-radius: 6px;
+}
+
+#copy-btn, #copy-link-btn, #set-title-btn, #reset-votes-btn {
+ background: transparent;
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ border-radius: 999px;
+ display: grid;
+ place-items: center;
+}
+
+#copy-btn:hover, #copy-link-btn:hover, #set-title-btn:hover, #reset-votes-btn:hover {
+ background: var(--bg-header-code);
+ border-color: var(--border-accent);
+}
+
+.header-actions button svg {
+ width: 20px;
+ height: 20px;
+ display: block;
+}
+
+#leave-btn {
+ background: transparent;
+ color: var(--accent-dark);
+ border: 1px solid var(--border-accent);
+}
+
+#leave-btn:hover {
+ background: var(--bg-header-code);
+}
+
+.add-section {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.add-section input {
+ flex: 1;
+}
+
+.bulk-section {
+ margin-bottom: 2rem;
+}
+
+.bulk-section textarea {
+ width: 100%;
+ min-height: 100px;
+ margin-bottom: 0.5rem;
+ resize: vertical;
+}
+
+/* List items */
+#list-container.disabled {
+ pointer-events: none;
+ opacity: 0.6;
+}
+
+.list-item {
+ background: var(--bg-surface);
+ padding: 1rem;
+ margin-bottom: 0.5rem;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px var(--shadow);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ transition: transform 0.3s ease, box-shadow 0.2s ease;
+ border-left: 3px solid transparent;
+}
+
+.list-item:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px var(--shadow);
+}
+
+.list-item.selected {
+ outline: 2px solid var(--border-accent);
+ outline-offset: 2px;
+}
+
+.list-item.vetoed {
+ opacity: 0.4;
+ max-height: 3rem;
+ overflow: hidden;
+}
+
+.list-item:has(.vote-btn.active) {
+ border-left-color: var(--accent);
+}
+
+.list-item:has(.vote-btn.veto-active) {
+ border-left-color: var(--accent-danger);
+}
+
+.list-item-text {
+ flex: 1;
+ padding-right: 1rem;
+ cursor: pointer;
+ user-select: none;
+}
+
+.list-item-text:hover {
+ opacity: 0.9;
+}
+
+.edit-input {
+ flex: 1;
+ margin-right: 1rem;
+}
+
+.list-item-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-shrink: 0;
+}
+
+.vote-btn {
+ padding: 0.5rem;
+ font-size: 0.9rem;
+}
+
+.delete-btn {
+ padding: 0.5rem;
+ font-size: 0.9rem;
+ background: var(--accent-danger);
+ opacity: 0.7;
+ transition: opacity 0.2s, background 0.2s;
+}
+
+.delete-btn:hover {
+ opacity: 1;
+ background: var(--accent-danger);
+}
+
+.vote-btn.active {
+ background: var(--accent-dark);
+}
+
+.vote-btn.veto-active {
+ background: var(--accent-danger);
+}
+
+.vote-btn svg, .delete-btn svg {
+ width: 16px;
+ height: 16px;
+ display: block;
+}
+
+.score {
+ min-width: 40px;
+ text-align: center;
+ font-weight: bold;
+ color: var(--accent);
+ background: var(--bg-header-code);
+ padding: 0.25rem 0.5rem;
+ border-radius: 999px;
+}
+
+/* Modal */
+.modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.modal-content {
+ background: var(--bg-surface);
+ padding: 2rem;
+ border-radius: 8px;
+ max-width: 500px;
+ width: 90%;
+ max-height: 80vh;
+ overflow-y: auto;
+ box-shadow: 0 4px 20px var(--shadow);
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 2px solid var(--border);
+}
+
+.modal-header h3 {
+ margin: 0;
+ color: var(--accent-dark);
+}
+
+.modal-close {
+ background: transparent;
+ color: var(--text-primary);
+ border: none;
+ font-size: 2rem;
+ line-height: 1;
+ padding: 0;
+ width: 2rem;
+ height: 2rem;
+ cursor: pointer;
+}
+
+.modal-close:hover {
+ color: var(--accent-danger);
+}
+
+.modal-body h4 {
+ color: var(--accent);
+ margin-top: 1rem;
+ margin-bottom: 0.5rem;
+}
+
+.modal-body h4:first-child {
+ margin-top: 0;
+}
+
+.modal-body ul {
+ margin: 0;
+ padding-left: 1.5rem;
+}
+
+.modal-body li {
+ margin-bottom: 0.5rem;
+}
+
+kbd {
+ display: inline-block;
+ padding: 0.2rem 0.4rem;
+ font-size: 0.85rem;
+ font-family: monospace;
+ background: var(--bg-header-code);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ box-shadow: 0 1px 2px var(--shadow);
+}
+
+/* Screen reader only */
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ * {
+ transition: none !important;
+ animation: none !important;
+ }
+}