feat: initial Sift collaborative list app

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

Change summary

.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(+)

Detailed changes

crush.json πŸ”—

@@ -0,0 +1,9 @@
+{
+	"$schema": "https://charm.land/crush.json",
+	"lsp": {
+		"deno": {
+			"command": "deno",
+			"args": ["lsp"]
+		}
+	}
+}

server.ts πŸ”—

@@ -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");

static/app.js πŸ”—

@@ -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);

static/index.html πŸ”—

@@ -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">&times;</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>

static/style.css πŸ”—

@@ -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;
+  }
+}