refactor: split large files into focused modules

Amolith created

Extract database operations to db.ts, rendering utilities to render.js,
and UI handlers to ui.js. This keeps files under 500 lines and improves
maintainability.

Also fix all lint issues:
- Replace 'as any' with proper message type interfaces
- Remove unused isReady variable
- Wrap switch case blocks with braces
- Replace window with globalThis

Implements: 09cd703
Assisted-by: Claude Sonnet 4.5 via Crush

Change summary

db.ts            | 303 +++++++++++++++++++++++++++++++++++++++++++
server.ts        | 347 ++++++++++---------------------------------------
static/app.js    | 245 +++++-----------------------------
static/render.js |  79 +++++++++++
static/ui.js     | 119 +++++++++++++++++
5 files changed, 611 insertions(+), 482 deletions(-)

Detailed changes

db.ts πŸ”—

@@ -0,0 +1,303 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+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
+    );
+  `);
+}
+
+export 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];
+        })
+      ) : {}
+    }))
+  };
+}
+
+export 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;
+}
+
+export function createRoom(code: string) {
+  db.prepare('INSERT INTO rooms (code) VALUES (?)').run(code);
+}
+
+export function roomExists(code: string): boolean {
+  const result = db.prepare('SELECT code FROM rooms WHERE code = ?').get(code);
+  return result !== undefined;
+}
+
+export function addItems(roomCode: string, items: Array<{id: string, text: string}>) {
+  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');
+    throw err;
+  }
+}
+
+export function upsertVote(itemId: string, userId: string, voteType: string) {
+  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);
+}
+
+export function deleteVote(itemId: string, userId: string) {
+  db.prepare('DELETE FROM votes WHERE item_id = ? AND user_id = ?').run(itemId, userId);
+}
+
+export function updateItemText(itemId: string, text: string) {
+  db.prepare('UPDATE items SET text = ? WHERE id = ?').run(text, itemId);
+}
+
+export function deleteItem(itemId: string) {
+  // 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);
+}
+
+export function resetVotes(roomCode: string) {
+  db.prepare(`
+    DELETE FROM votes 
+    WHERE item_id IN (
+      SELECT id FROM items WHERE room_code = ?
+    )
+  `).run(roomCode);
+}
+
+export function updateRoomTitle(roomCode: string, title: string | null) {
+  db.prepare('UPDATE rooms SET title = ? WHERE code = ?').run(title, roomCode);
+}
+
+export function getItemsWithVotes(roomCode: string) {
+  return db.prepare(`
+    SELECT i.id, i.text,
+           GROUP_CONCAT(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
+  `).all(roomCode) as Array<{id: string, text: string, votes: string | null}>;
+}

server.ts πŸ”—

@@ -3,201 +3,51 @@
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
 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";
+import * as db from "./db.ts";
 
-const db = new Database("lists.db");
+// WebSocket message types
+interface AddItemsMessage {
+  type: 'add_items';
+  items: unknown;
+}
 
-// Enable foreign key constraints
-db.exec("PRAGMA foreign_keys = ON;");
+interface VoteMessage {
+  type: 'vote';
+  itemId: unknown;
+  voteType: unknown;
+}
 
-// Check schema version and migrate if needed
-const currentVersion = (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version;
+interface UnvoteMessage {
+  type: 'unvote';
+  itemId: unknown;
+}
 
-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
-    );
-  `);
+interface EditItemMessage {
+  type: 'edit_item';
+  itemId: unknown;
+  text: unknown;
+}
+
+interface DeleteItemMessage {
+  type: 'delete_item';
+  itemId: unknown;
+}
+
+interface ResetVotesMessage {
+  type: 'reset_votes';
+}
+
+interface SetTitleMessage {
+  type: 'set_title';
+  title: unknown;
 }
 
+interface BreakTieMessage {
+  type: 'break_tie';
+}
+
+type ClientMessage = AddItemsMessage | VoteMessage | UnvoteMessage | EditItemMessage | DeleteItemMessage | ResetVotesMessage | SetTitleMessage | BreakTieMessage;
+
 const clients = new Map<string, Set<WebSocket>>();
 
 function generateRoomId(): string {
@@ -252,40 +102,6 @@ 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)) {
@@ -295,7 +111,7 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
 
   ws.onopen = () => {
     // Send current state once connection is open
-    const state = getState(roomCode);
+    const state = db.getState(roomCode);
     ws.send(JSON.stringify({
       type: 'state',
       items: state.items,
@@ -306,12 +122,12 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
 
   ws.onmessage = (event) => {
     const msg = safeParseMessage(ws, event.data);
-    if (!msg || typeof msg !== 'object') return;
+    if (!msg || typeof msg !== 'object' || !('type' in msg)) return;
     
     try {
-      switch ((msg as any).type) {
+      switch ((msg as ClientMessage).type) {
         case 'add_items': {
-          const rawItems = (msg as any).items;
+          const rawItems = (msg as AddItemsMessage).items;
           if (!Array.isArray(rawItems)) {
             console.warn('add_items: items is not an array');
             return;
@@ -336,14 +152,8 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
           }
           
           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');
+            db.addItems(roomCode, items);
           } catch (err) {
-            db.exec('ROLLBACK');
             console.error('add_items transaction failed:', err);
             return;
           }
@@ -356,7 +166,7 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
         }
         
         case 'vote': {
-          const { itemId, voteType } = msg as any;
+          const { itemId, voteType } = msg as VoteMessage;
           
           if (typeof itemId !== 'string') {
             console.warn('vote: itemId is not a string');
@@ -368,17 +178,12 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
             return;
           }
           
-          if (!itemBelongsToRoom(itemId, roomCode)) {
+          if (!db.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);
+          db.upsertVote(itemId, userId, voteType);
           
           broadcast(roomCode, {
             type: 'vote_changed',
@@ -390,20 +195,19 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
         }
         
         case 'unvote': {
-          const { itemId } = msg as any;
+          const { itemId } = msg as UnvoteMessage;
           
           if (typeof itemId !== 'string') {
             console.warn('unvote: itemId is not a string');
             return;
           }
           
-          if (!itemBelongsToRoom(itemId, roomCode)) {
+          if (!db.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);
+          db.deleteVote(itemId, userId);
           
           broadcast(roomCode, {
             type: 'vote_removed',
@@ -414,7 +218,7 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
         }
         
         case 'edit_item': {
-          const { itemId, text } = msg as any;
+          const { itemId, text } = msg as EditItemMessage;
           
           if (typeof itemId !== 'string') {
             console.warn('edit_item: itemId is not a string');
@@ -432,13 +236,12 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
             return;
           }
           
-          if (!itemBelongsToRoom(itemId, roomCode)) {
+          if (!db.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);
+          db.updateItemText(itemId, sanitized);
           
           broadcast(roomCode, {
             type: 'item_edited',
@@ -449,22 +252,19 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
         }
         
         case 'delete_item': {
-          const { itemId } = msg as any;
+          const { itemId } = msg as DeleteItemMessage;
           
           if (typeof itemId !== 'string') {
             console.warn('delete_item: itemId is not a string');
             return;
           }
           
-          if (!itemBelongsToRoom(itemId, roomCode)) {
+          if (!db.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);
+          db.deleteItem(itemId);
           
           broadcast(roomCode, {
             type: 'item_deleted',
@@ -474,13 +274,7 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
         }
         
         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);
+          db.resetVotes(roomCode);
           
           broadcast(roomCode, {
             type: 'votes_reset'
@@ -489,7 +283,7 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
         }
         
         case 'set_title': {
-          const { title } = msg as any;
+          const { title } = msg as SetTitleMessage;
           
           let sanitized: string | null = null;
           if (title !== null && title !== undefined) {
@@ -504,8 +298,7 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
             }
           }
           
-          db.prepare('UPDATE rooms SET title = ? WHERE code = ?')
-            .run(sanitized, roomCode);
+          db.updateRoomTitle(roomCode, sanitized);
           
           broadcast(roomCode, {
             type: 'title_changed',
@@ -516,14 +309,7 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
         
         case 'break_tie': {
           // Get all items in the room with their votes
-          const items = db.prepare(`
-            SELECT i.id, i.text,
-                   GROUP_CONCAT(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
-          `).all(roomCode) as Array<{id: string, text: string, votes: string | null}>;
+          const items = db.getItemsWithVotes(roomCode);
           
           // Filter to non-vetoed items and calculate scores
           const nonVetoed = items
@@ -601,6 +387,14 @@ serve(async (req) => {
     const js = await Deno.readTextFile("./static/app.js");
     return new Response(js, { headers: { "content-type": "application/javascript" } });
   }
+  if (url.pathname === "/render.js") {
+    const js = await Deno.readTextFile("./static/render.js");
+    return new Response(js, { headers: { "content-type": "application/javascript" } });
+  }
+  if (url.pathname === "/ui.js") {
+    const js = await Deno.readTextFile("./static/ui.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 {
@@ -614,7 +408,7 @@ serve(async (req) => {
   // Create new room
   if (url.pathname === "/api/create") {
     const code = generateRoomId();
-    db.prepare('INSERT INTO rooms (code) VALUES (?)').run(code);
+    db.createRoom(code);
     return Response.json({ code });
   }
 
@@ -628,8 +422,7 @@ serve(async (req) => {
     }
 
     // Verify room exists
-    const roomExists = db.prepare('SELECT code FROM rooms WHERE code = ?').get(roomCode);
-    if (!roomExists) {
+    if (!db.roomExists(roomCode)) {
       return new Response("Room not found", { status: 404 });
     }
 

static/app.js πŸ”—

@@ -2,6 +2,9 @@
 //
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
+import { getSortedItems, escapeHtml, updateConnectionStatus, updateLastSync, setUIEnabled } from './render.js';
+import { setupKeyboardShortcuts, setupHelpModal, setupPressLock } from './ui.js';
+
 let ws = null;
 let currentRoom = null;
 let roomTitle = null;
@@ -13,7 +16,6 @@ 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
 let shouldScrollSelectedIntoView = false; // Only scroll on local actions
 
 const CHECK_ICON_SVG = `<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" class="feather feather-check"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
@@ -119,8 +121,7 @@ document.getElementById('set-title-btn').addEventListener('click', () => {
     }
   });
   
-  // Insert input after the header div
-  const header = listScreen.querySelector('header');
+  // Insert input near title element
   if (roomTitleEl.classList.contains('hidden')) {
     roomTitleEl.parentElement.insertBefore(input, roomTitleEl);
   } else {
@@ -174,8 +175,7 @@ let messageQueue = [];
 function joinRoom(code) {
   currentRoom = code;
   
-  isReady = false;
-  setUIEnabled(false);
+  setUIEnabled(false, listScreen, listContainer);
   updateConnectionStatus('connecting');
   
   const wsScheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -192,7 +192,7 @@ function joinRoom(code) {
   ws.onmessage = (event) => {
     const msg = JSON.parse(event.data);
     lastSyncTime = Date.now();
-    updateLastSync();
+    updateLastSync(lastSyncTime);
     handleMessage(msg);
   };
   
@@ -202,8 +202,7 @@ function joinRoom(code) {
   };
   
   ws.onclose = (event) => {
-    isReady = false;
-    setUIEnabled(false);
+    setUIEnabled(false, listScreen, listContainer);
     updateConnectionStatus('disconnected');
     if (!event.wasClean) {
       console.error('Connection closed unexpectedly');
@@ -231,15 +230,14 @@ function sendMessage(msg) {
 
 function handleMessage(msg) {
   switch (msg.type) {
-    case 'state':
+    case 'state': {
       items = msg.items;
       userId = msg.userId;
       roomTitle = msg.roomTitle;
       if (items.length > 0 && !selectedItemId) {
-        selectedItemId = getSortedItems()[0].id;
+        selectedItemId = getSortedItems(items)[0].id;
       }
-      isReady = true;
-      setUIEnabled(true);
+      setUIEnabled(true, listScreen, listContainer);
       
       // Update URL to include room code for easy copying
       const newUrl = `${location.origin}${location.pathname}?room=${currentRoom}`;
@@ -248,20 +246,21 @@ function handleMessage(msg) {
       renderTitle();
       render();
       break;
+    }
     case 'items_added':
       items.push(...msg.items);
       if (!selectedItemId && items.length > 0) {
-        selectedItemId = getSortedItems()[0].id;
+        selectedItemId = getSortedItems(items)[0].id;
       }
       render();
       break;
-    case 'vote_changed':
+    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 sorted = getSortedItems(items);
           const clampedPos = Math.min(selectedPosition, sorted.length - 1);
           selectedItemId = sorted[clampedPos].id;
           selectedPosition = null;
@@ -269,13 +268,14 @@ function handleMessage(msg) {
         render();
       }
       break;
-    case 'vote_removed':
+    }
+    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 sorted = getSortedItems(items);
           const clampedPos = Math.min(selectedPosition, sorted.length - 1);
           selectedItemId = sorted[clampedPos].id;
           selectedPosition = null;
@@ -283,16 +283,18 @@ function handleMessage(msg) {
         render();
       }
       break;
-    case 'item_edited':
+    }
+    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 sorted = getSortedItems(items);
         const idx = sorted.findIndex(i => i.id === msg.itemId);
         const nextIdx = Math.min(idx, sorted.length - 2);
         selectedItemId = nextIdx >= 0 ? sorted[nextIdx].id : null;
@@ -321,7 +323,7 @@ function vote(itemId, voteType) {
   const currentVote = item?.votes[userId];
   
   // Remember current position to select item at same position after re-sort
-  const sorted = getSortedItems();
+  const sorted = getSortedItems(items);
   selectedPosition = sorted.findIndex(i => i.id === itemId);
   shouldScrollSelectedIntoView = true;
   
@@ -420,173 +422,6 @@ 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;
-    shouldScrollSelectedIntoView = true;
-    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;
-    shouldScrollSelectedIntoView = true;
-    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();
@@ -597,7 +432,7 @@ function render() {
   });
   
   // Sort: vetoed items last, then by score
-  const sorted = getSortedItems();
+  const sorted = getSortedItems(items);
   
   // Check if any votes have been cast
   const hasAnyVotes = sorted.some(item => Object.keys(item.votes).length > 0);
@@ -689,12 +524,6 @@ function render() {
   }
 }
 
-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]');
@@ -725,19 +554,25 @@ listContainer.addEventListener('dblclick', (e) => {
   editItem(itemId);
 });
 
-// Minimal press-lock for non-vote buttons to smooth release
-document.addEventListener('click', (e) => {
-  const btn = e.target.closest('button');
-  if (!btn) return;
-  if (btn.classList.contains('vote-btn')) return; // handled in vote()
-  btn.classList.add('press-lock');
-  setTimeout(() => btn.classList.remove('press-lock'), 180);
+// Initialize UI components
+setupHelpModal();
+setupPressLock();
+setupKeyboardShortcuts({
+  items,
+  vote,
+  deleteItem,
+  editItem,
+  undo,
+  render,
+  getSelectedItemId: () => selectedItemId,
+  setSelectedItemId: (id) => { selectedItemId = id; },
+  setShouldScroll: (value) => { shouldScrollSelectedIntoView = value; }
 });
 
 // Make functions global
-window.vote = vote;
-window.deleteItem = deleteItem;
-window.editItem = editItem;
+globalThis.vote = vote;
+globalThis.deleteItem = deleteItem;
+globalThis.editItem = editItem;
 
 // Deep linking: auto-join room from URL param
 const urlParams = new URLSearchParams(location.search);
@@ -750,4 +585,4 @@ if (roomParam) {
 }
 
 // Update sync status every second
-setInterval(updateLastSync, 1000);
+setInterval(() => updateLastSync(lastSyncTime), 1000);

static/render.js πŸ”—

@@ -0,0 +1,79 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+export function getSortedItems(items) {
+  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;
+  });
+}
+
+export function escapeHtml(text) {
+  const div = document.createElement('div');
+  div.textContent = text;
+  return div.innerHTML;
+}
+
+export 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`;
+}
+
+export function updateLastSync(lastSyncTime) {
+  const lastSyncEl = document.querySelector('.last-sync');
+  if (!lastSyncTime) {
+    lastSyncEl.textContent = '';
+    return;
+  }
+  
+  lastSyncEl.textContent = 'β€’ ' + formatTimeSince(lastSyncTime);
+}
+
+export function setUIEnabled(enabled, listScreen, listContainer) {
+  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');
+  }
+}

static/ui.js πŸ”—

@@ -0,0 +1,119 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { getSortedItems } from './render.js';
+
+export function setupKeyboardShortcuts(options) {
+  const { items, vote, deleteItem, editItem, undo, render, getSelectedItemId, setSelectedItemId, setShouldScroll } = options;
+  
+  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(items);
+    const selectedItemId = getSelectedItemId();
+    
+    // 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);
+      setSelectedItemId(sorted[nextIdx].id);
+      setShouldScroll(true);
+      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);
+      setSelectedItemId(sorted[prevIdx].id);
+      setShouldScroll(true);
+      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
+export function setupHelpModal() {
+  const helpModal = document.getElementById('help-modal');
+  const helpModalClose = helpModal.querySelector('.modal-close');
+  
+  helpModalClose.addEventListener('click', closeHelpModal);
+  
+  // Close modal on backdrop click
+  helpModal.addEventListener('click', (e) => {
+    if (e.target === helpModal) {
+      closeHelpModal();
+    }
+  });
+}
+
+export function openHelpModal() {
+  const helpModal = document.getElementById('help-modal');
+  const helpModalClose = helpModal.querySelector('.modal-close');
+  helpModal.classList.remove('hidden');
+  helpModalClose.focus();
+}
+
+export function closeHelpModal() {
+  const helpModal = document.getElementById('help-modal');
+  helpModal.classList.add('hidden');
+}
+
+export function setupPressLock() {
+  // Minimal press-lock for non-vote buttons to smooth release
+  document.addEventListener('click', (e) => {
+    const btn = e.target.closest('button');
+    if (!btn) return;
+    if (btn.classList.contains('vote-btn')) return; // handled in vote()
+    btn.classList.add('press-lock');
+    setTimeout(() => btn.classList.remove('press-lock'), 180);
+  });
+}