AGENTS.md

AGENTS.md

Guide for AI agents working in this codebase.

Project Overview

Sift is a collaborative list application with real-time voting and ranking. Users create or join rooms via an ID, then collaboratively add items and vote (upvote, downvote, or veto). Items are automatically sorted by score, with vetoed items pushed to the bottom and dimmed. Rooms can optionally have custom titles.

Tech stack:

Essential Commands

Run the server

deno run --allow-net=:8294 --allow-read=./static/,./lists.db,$HOME/.cache/deno/plug --allow-write=./lists.db,$HOME/.cache/deno/plug --allow-env --allow-ffi server.ts

CLI options:

  • --host <hostname>: Bind to specific interface (default: localhost, use 0.0.0.0 for all interfaces)
  • --port <port>: Listen on specific port (default: 8294)

Example with custom host/port:

deno run --allow-net=:3000 --allow-read=./static/,./lists.db,$HOME/.cache/deno/plug --allow-write=./lists.db,$HOME/.cache/deno/plug --allow-env --allow-ffi server.ts --host 0.0.0.0 --port 3000

Required permissions:

  • --allow-net=:PORT: HTTP server and WebSocket connections (update PORT to match --port flag)
  • --allow-read: Serve static files from ./static/, read database, cache SQLite library
  • --allow-write: SQLite database operations and cache directory for native library
  • --allow-env: SQLite library reads environment variables (unavoidable for sqlite3 FFI)
  • --allow-ffi: SQLite3 native library access (unavoidable for sqlite3)

Server listens on http://HOST:PORT (defaults to http://localhost:8294)

Database inspection

sqlite3 lists.db ".schema"
sqlite3 lists.db "SELECT * FROM rooms;"
sqlite3 lists.db "SELECT * FROM items;"
sqlite3 lists.db "SELECT * FROM votes;"

Architecture

Data Flow

User creates/joins room β†’ WebSocket connection β†’ Server sends current state (including room title)
User adds items β†’ Client sends 'add_items' β†’ Server inserts to DB β†’ Broadcasts to all clients in room
User votes β†’ Client sends 'vote' β†’ Server upserts vote β†’ Broadcasts vote change
User deletes item β†’ Client sends 'delete_item' β†’ Server cascades deletion (and explicitly deletes votes) β†’ Broadcasts deletion
User sets room title β†’ Client sends 'set_title' β†’ Server updates room β†’ Broadcasts title change
Top tie present β†’ Client may send 'break_tie' β†’ Server randomly selects a winner among tied top items β†’ Broadcasts 'tie_broken'

Component Structure

  • server.ts
    • HTTP request handler serving static files and API endpoints
    • WebSocket connection manager with room-based broadcasting
    • Validation of message size, payload types, and room/item membership
    • Schedules daily cleanup of inactive rooms (see db.deleteInactiveRooms)
  • db.ts
    • SQLite database initialization, foreign keys, schema migration (v0β†’v1β†’v2β†’v3)
    • Query helpers for state assembly, item/vote CRUD, room title updates
    • Inactive room cleanup by last activity timestamp (created_at of rooms/items/votes)
  • static/index.html
    • Two-screen UI: start screen (create/join) and list screen
    • Connection status indicators (dot + text + last sync time)
    • Header actions (set title, copy link, copy items, reset votes, leave)
    • Help modal markup and keyboard shortcut reference
    • Input methods: single-item input and bulk textarea
    • Inline SVG icons embedded in buttons
    • Loads ES module script with <script type="module" src="/app.js"></script>
  • static/app.js (ES module entry)
    • WebSocket client with message queuing before connection ready
    • State management for items, votes, selected item, room title
    • URL deep-linking and history updates
    • Delegates rendering/utilities to render.js and keyboard/help UX to ui.js
  • static/render.js
    • Sorting, escaping, connection status, last-sync display, and enabled/disabled UI toggling
  • static/ui.js
    • Keyboard shortcuts (j/k nav, 1/2/3 votes, Enter upvote, e edit, Del delete, Ctrl/Cmd+Z undo, ? help)
    • Help modal open/close and minimal press-lock for buttons
  • static/style.css, static/palette.css
    • CSS custom properties, light/dark theme, responsive layout, reduced motion support

Database Schema (schema_version = 3)

rooms

  • code (TEXT PRIMARY KEY NOT NULL)
  • created_at (INTEGER, Unix timestamp)
  • title (TEXT, nullable, added in v3)

items

  • id (TEXT PRIMARY KEY NOT NULL)
  • room_code (TEXT NOT NULL) β†’ FOREIGN KEY rooms(code) ON DELETE CASCADE
  • text (TEXT NOT NULL) CHECK(LENGTH(text) BETWEEN 1 AND 200)
  • created_at (INTEGER, Unix timestamp)
  • Index: idx_items_room_code on room_code

votes

  • item_id (TEXT NOT NULL) β†’ FOREIGN KEY items(id) ON DELETE CASCADE
  • user_id (TEXT NOT NULL)
  • vote_type (TEXT NOT NULL) CHECK(vote_type IN ('up','down','veto'))
  • created_at (INTEGER, Unix timestamp)
  • PRIMARY KEY (item_id, user_id)
  • Indexes: idx_votes_item_id on item_id, idx_votes_user_id on user_id

Constraints

  • PRAGMA foreign_keys = ON
  • CASCADE deletion: deleting a room deletes all items and votes; deleting an item deletes all votes (code also deletes votes explicitly before deleting an item)
  • NOT NULL validation at DB level
  • Text length enforced 1–200 characters
  • Vote type enforced: 'up' | 'down' | 'veto'

Migration

  • On startup, migrates legacy data to latest schema when needed
  • From v0: creates new tables, sanitizes/truncates item text to 200 chars, filters vote types; sets user_version = 2
  • From v1: rebuilds rooms table (removes last_activity and length constraint); sets user_version = 2
  • From v2: adds title column to rooms; sets user_version = 3
  • For fresh databases: creates tables with schema v3 directly
  • Transactional (BEGIN/COMMIT with rollback on failure)

WebSocket Protocol

Server β†’ Client messages

{ type: 'state', items: [...], roomTitle: string|null, userId: '...' }
{ type: 'items_added', items: [{id, text, votes}, ...] }
{ type: 'vote_changed', itemId, userId, voteType }
{ type: 'vote_removed', itemId, userId }
{ type: 'item_edited', itemId, text }
{ type: 'item_deleted', itemId }
{ type: 'votes_reset' }
{ type: 'title_changed', title: string|null }
{ type: 'tie_broken', itemId, text }

Client β†’ Server messages

{ type: 'add_items', items: ['text1', 'text2', ...] }
{ type: 'vote', itemId, voteType }    // voteType: 'up'|'down'|'veto'
{ type: 'unvote', itemId }
{ type: 'edit_item', itemId, text }
{ type: 'delete_item', itemId }
{ type: 'reset_votes' }
{ type: 'set_title', title: string|null }  // null to clear title
{ type: 'break_tie' } // request server to randomly choose among tied top items

State Synchronization & Maintenance

Connection lifecycle

  1. Client connects with ?room=CODE&user=UUID query params
  2. Server verifies room exists, upgrades to WebSocket
  3. Server sends full state via 'state' message (items, roomTitle, userId)
  4. Client stores userId from server (may differ from localStorage if not provided)
  5. Client sets isReady=true and enables UI
  6. Subsequent changes broadcast to all clients in room (sender included)

Client-side optimism: None β€” server is authoritative. Client waits for broadcast confirmation.

Room cleanup

  • Socket close: connection is removed from the in-memory clients Map
  • Inactive rooms: daily job deletes rooms with no activity for 30 days (based on max of room/items/votes created_at)

Connection status: Client tracks connection state (connecting/connected/disconnected) with visual indicators and last sync timestamp, updated every second.

Rendering Logic

Client-side sort

  1. Vetoed items always appear last
  2. Within non-vetoed and vetoed groups, sort by score (descending)
  3. Score = count(upvotes) - count(downvotes), vetoes don't affect score

Visual states

  • Vetoed items: 40% opacity, max-height constraint, hidden overflow
  • Active vote buttons: darker background (press-lock to avoid flicker)
  • Selected item: outline with accent color
  • Items display current score with +/- prefix
  • Highest-scoring non-vetoed items get a left "top-check" indicator
  • Reordering uses FLIP animation

Tie breaker

  • When a top score tie exists, a "Break the tie" UI appears
  • Clicking it sends 'break_tie'; server broadcasts 'tie_broken' with the chosen item for display

Room title

  • Displayed in header when set
  • Hidden when null/empty
  • Editable via set-title-btn (pencil icon)
  • Sanitized using same rules as item text (1-200 chars)

Server-Side Validation

Limits (server.ts)

  • MAX_ITEM_TEXT_LEN: 200 characters
  • MAX_BULK_ITEMS: 100 items per add_items request
  • MAX_WS_MESSAGE_BYTES: 32768 bytes (32KB)

Message validation

  • Messages exceeding MAX_WS_MESSAGE_BYTES close the WebSocket with code 1009
  • Invalid JSON is logged and ignored
  • add_items: items must be array length 1–100; each text sanitized (trim, normalize CR/LF); texts outside 1–200 chars are rejected
  • vote/unvote/delete_item/edit_item: itemId must be string and belong to current room
  • vote: voteType must be one of 'up'|'down'|'veto'
  • edit_item: text sanitized and length-checked (1–200)
  • reset_votes: clears all votes for items in the room
  • set_title: title can be null/undefined (clears title) or string; if string, sanitized and length-checked (1–200)
  • break_tie: server computes tie among top non-vetoed items and broadcasts a random winner

Validation helpers

  • safeParseMessage(): size check + JSON parse with error handling
  • sanitizeItemText(): trim, normalize line endings; returns null if length invalid
  • isValidVoteType(): type guard for vote types
  • itemBelongsToRoom(): prevents cross-room item manipulation

Invalid messages

  • Logged with descriptive warnings
  • Silently ignored (no error response)
  • Never throw exceptions that crash the server

Code Patterns

Naming conventions

  • Variables: camelCase (roomCode, userId, messageQueue, roomTitle)
  • Constant limits: UPPER_SNAKE_CASE (MAX_ITEM_TEXT_LEN, MAX_BULK_ITEMS, MAX_WS_MESSAGE_BYTES)
  • Other constants/instances: camelCase (db, clients)
  • Database columns: snake_case (room_code, created_at, vote_type)
  • CSS custom properties: kebab-case with -- prefix (--bg-page, --accent-hover)

TypeScript usage

  • Minimal typing, relying on inference
  • Explicit types for function parameters when needed
  • Type assertions for DB results (e.g., as Array<{...}>)
  • Type guard for vote types

Error handling

  • Server WebSocket messages: try/catch around message handling
  • Server: no error responses for malformed WebSocket messages
  • Client WebSocket: error handler logs to console
  • Client close: alert on unexpected close, reset to start screen
  • Clipboard actions: try/catch with console log
  • Delete confirmation: browser confirm() dialog

Security notes

  • No authentication: rooms protected only by knowing the room ID
  • User IDs: client-generated UUIDs in localStorage (spoofable)
  • XSS prevention: escapeHtml() used when rendering item text and room title
  • Input validation: server-side sanitization and length limits
  • Cross-room protection: server validates item-room membership
  • Message size limit: WebSocket messages limited to 32KB
  • Foreign keys: enabled with CASCADE deletion

Gotchas

  1. Vote uniqueness: DB enforces one vote per user per item via compound PK and UPSERT. Clicking the same vote button twice removes the vote.
  2. Message queuing: Client queues messages sent before WebSocket opens and flushes them on connection.
  3. Undo is limited: Only stores last vote action; doesn't cover add/delete operations.
  4. Broadcast 'except' param unused by callers: all clients, including sender, receive broadcasts.
  5. Item-room validation enforced: prevents cross-room vote/edit/delete.
  6. Port is hardcoded: server always runs on port 8294.
  7. Static files served via dedicated routes; icons are inline in HTML. An /icons/ route exists for optional SVGs if added under static/icons/.
  8. No build step: Deno runs TypeScript directly.
  9. Database file location: lists.db is created in the project root.
  10. Deep linking works: Opening a URL with ?room=CODE auto-joins that room.
  11. UI disabled while connecting: Inputs/buttons disabled until 'state' message received (isReady flag).
  12. Room title is optional: null/empty titles are hidden; title uses same text sanitization as items.
  13. Position restoration after voting: Client remembers position in sorted list and re-selects item at same position after vote-triggered re-sort.
  14. ES modules: index.html loads app.js as type=module which imports render.js and ui.js.
  15. Cleanup: a daily job deletes rooms with no activity for 30 days; this is irreversible.

Testing Approach

No automated tests. Manual checks:

Core functionality

  1. Multi-client testing: open multiple tabs to the same room
  2. Vote synchronization: vote in one client; others update
  3. Bulk add: paste multi-line text; verify all items added
  4. Edit item: edit an item; verify all clients update (item_edited)
  5. Reset votes: use the reset button; verify votes clear (votes_reset)
  6. Set room title: use set title button; verify all clients see updated title
  7. Veto sorting: veto an item; verify it moves to bottom and dims
  8. Disconnection: stop server; clients show alert and reset
  9. Room joining: joining a non-existent room returns 404
  10. Deep linking: open URL with ?room=CODE; verify auto-join
  11. Connection status: watch status dot change and last sync time update
  12. Keyboard shortcuts: test j/k navigation, 1/2/3 voting, e edit, Del delete, ? help
  13. Top tie: when multiple non-vetoed items share the highest score, "Break the tie" appears; clicking it shows a "Winner: ..." message (tie_broken)
  14. Noscript fallback: with JS disabled, verify noscript message displays

Validation testing (check server logs)

  1. Empty item: whitespace-only text β†’ ignored, logged
  2. Long item: >200 characters β†’ rejected, logged (not truncated at runtime)
  3. Bulk limit: >100 items in one request β†’ rejected, logged
  4. Invalid vote type: crafted invalid voteType β†’ ignored, logged
  5. Cross-room manipulation: vote/edit/delete for an item in another room β†’ rejected, logged
  6. Large message: >32KB WebSocket message β†’ socket closed with code 1009
  7. DB constraints: sqlite3 lists.db ".schema" β†’ verify CHECKs, NOT NULL, indexes
  8. Migration: start with old-format data; verify migration to v3 runs and data preserved
  9. Title validation: set title >200 chars β†’ rejected, logged; set null title β†’ clears title

Development Workflow

Making changes

  1. Edit source files (server.ts, db.ts, or static/*)
  2. Restart Deno server (no hot reload)
  3. Hard refresh browser (Ctrl+Shift+R) to bypass cache
  4. Test in multiple tabs for WebSocket sync

Adding new vote types

  1. Update DB schema CHECK constraint for vote_type in CREATE TABLE votes (and migrations)
  2. Update isValidVoteType in server.ts
  3. Update vote aggregation/rendering logic on client if needed
  4. Add button in static/index.html
  5. Add handling in static/app.js vote() and render()
  6. Add CSS styling for the new vote type

Adding new message types

  1. Add a case to the switch in handleWebSocket() in server.ts
  2. Implement DB operations and broadcast() as needed
  3. Add a case to handleMessage() in static/app.js
  4. Update client state and trigger render()

File Organization

/
β”œβ”€β”€ server.ts          # Deno HTTP/WebSocket server
β”œβ”€β”€ db.ts              # SQLite schema, migrations, queries, cleanup
β”œβ”€β”€ lists.db           # SQLite database (created on first run)
└── static/
    β”œβ”€β”€ index.html     # HTML structure
    β”œβ”€β”€ app.js         # Entry module; WS client and state
    β”œβ”€β”€ render.js      # Rendering/util helpers
    β”œβ”€β”€ ui.js          # Keyboard/help UI logic
    β”œβ”€β”€ style.css      # Styles with light/dark theme
    └── palette.css    # Color tokens and CSS variables

No build artifacts, no dependencies beyond Deno stdlib and sqlite3. An optional static/icons/ directory may be added if serving SVGs via /icons/.

Important Constants

  • Room ID generation: crypto.randomUUID()
  • Default server host: localhost (configurable via --host)
  • Default server port: 8294 (configurable via --port)
  • WebSocket path: /ws
  • API endpoints: /api/create (POST, returns room code)
  • Default userId: generated via crypto.randomUUID() and stored in localStorage

Validation limits

  • MAX_ITEM_TEXT_LEN: 200 characters (applies to both items and room titles)
  • MAX_BULK_ITEMS: 100
  • MAX_WS_MESSAGE_BYTES: 32768 (32KB)

Dependencies

All dependencies are imported via HTTPS URLs from deno.land:

No package.json, no npm, no node_modules.